codex额度查看工具开发完成1.0版本
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, systemPreferences, Tray } from 'electron'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { UsageReader } from './usage-reader.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const SINGLE_TILE_WIDTH = 120
|
||||
const DOUBLE_TILE_WIDTH = 236
|
||||
const WINDOW_HEIGHT = 60
|
||||
const DOCK_WIDTH = 12
|
||||
const DOCK_TRIGGER_DISTANCE = 16
|
||||
const DOCK_ANIMATION_DURATION = 210
|
||||
const screenshotArgument = process.argv.find((argument) => argument.startsWith('--screenshot='))
|
||||
const screenshotPath = screenshotArgument?.slice('--screenshot='.length)
|
||||
|
||||
if (screenshotPath) {
|
||||
app.setPath('userData', path.join(os.tmpdir(), `codex-halo-preview-${process.pid}`))
|
||||
}
|
||||
|
||||
let mainWindow
|
||||
let tray
|
||||
let usageReader
|
||||
let isQuitting = false
|
||||
let saveBoundsTimer
|
||||
let dragState = null
|
||||
let normalWindowWidth = SINGLE_TILE_WIDTH
|
||||
let isDocked = false
|
||||
let isDockCollapsed = false
|
||||
let dockTimer
|
||||
let dockAnimationTimer
|
||||
|
||||
function settingsPath() {
|
||||
return path.join(app.getPath('userData'), 'window-state.json')
|
||||
}
|
||||
|
||||
function readWindowState() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleOnAnyDisplay(bounds) {
|
||||
return screen.getAllDisplays().some(({ workArea }) => {
|
||||
const horizontal = bounds.x < workArea.x + workArea.width && bounds.x + bounds.width > workArea.x
|
||||
const vertical = bounds.y < workArea.y + workArea.height && bounds.y + bounds.height > workArea.y
|
||||
return horizontal && vertical
|
||||
})
|
||||
}
|
||||
|
||||
function initialBounds() {
|
||||
const saved = readWindowState()
|
||||
if (saved && isVisibleOnAnyDisplay(saved)) {
|
||||
return {
|
||||
width: SINGLE_TILE_WIDTH,
|
||||
height: WINDOW_HEIGHT,
|
||||
x: saved.x + Math.max(0, saved.width - SINGLE_TILE_WIDTH),
|
||||
y: saved.y,
|
||||
}
|
||||
}
|
||||
|
||||
const { workArea } = screen.getPrimaryDisplay()
|
||||
return {
|
||||
width: SINGLE_TILE_WIDTH,
|
||||
height: WINDOW_HEIGHT,
|
||||
x: workArea.x + workArea.width - SINGLE_TILE_WIDTH - 28,
|
||||
y: workArea.y + 28,
|
||||
}
|
||||
}
|
||||
|
||||
function resizeForSnapshot(snapshot) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const now = Date.now()
|
||||
const hasShortWindow = snapshot.shortWindow?.resetsAt > now
|
||||
const hasWeekWindow = snapshot.weekWindow?.resetsAt > now
|
||||
const width = hasShortWindow && hasWeekWindow ? DOUBLE_TILE_WIDTH : SINGLE_TILE_WIDTH
|
||||
const bounds = mainWindow.getBounds()
|
||||
normalWindowWidth = width
|
||||
|
||||
if (isDocked) {
|
||||
clearInterval(dockAnimationTimer)
|
||||
const { workArea } = screen.getDisplayMatching(bounds)
|
||||
const dockWidth = isDockCollapsed ? DOCK_WIDTH : normalWindowWidth
|
||||
mainWindow.setBounds({
|
||||
x: workArea.x + workArea.width - dockWidth,
|
||||
y: bounds.y,
|
||||
width: dockWidth,
|
||||
height: WINDOW_HEIGHT,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (bounds.width !== width || bounds.height !== WINDOW_HEIGHT) {
|
||||
mainWindow.setBounds({
|
||||
x: bounds.x + bounds.width - width,
|
||||
y: bounds.y,
|
||||
width,
|
||||
height: WINDOW_HEIGHT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function saveWindowState() {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || isDocked) return
|
||||
clearTimeout(saveBoundsTimer)
|
||||
saveBoundsTimer = setTimeout(() => {
|
||||
if (isDocked) return
|
||||
writeFileSync(settingsPath(), JSON.stringify(mainWindow.getBounds()))
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function setDockCollapsed(collapsed, animated = true) {
|
||||
if (!isDocked || !mainWindow || mainWindow.isDestroyed()) return
|
||||
const bounds = mainWindow.getBounds()
|
||||
const { workArea } = screen.getDisplayMatching(bounds)
|
||||
const targetWidth = collapsed ? DOCK_WIDTH : normalWindowWidth
|
||||
const rightEdge = workArea.x + workArea.width
|
||||
const y = Math.max(
|
||||
workArea.y,
|
||||
Math.min(bounds.y, workArea.y + workArea.height - WINDOW_HEIGHT),
|
||||
)
|
||||
isDockCollapsed = collapsed
|
||||
clearInterval(dockAnimationTimer)
|
||||
|
||||
if (!animated || systemPreferences.getAnimationSettings().prefersReducedMotion) {
|
||||
mainWindow.setBounds({ x: rightEdge - targetWidth, y, width: targetWidth, height: WINDOW_HEIGHT })
|
||||
return
|
||||
}
|
||||
|
||||
const startWidth = bounds.width
|
||||
const startedAt = Date.now()
|
||||
dockAnimationTimer = setInterval(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
clearInterval(dockAnimationTimer)
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Math.min(1, (Date.now() - startedAt) / DOCK_ANIMATION_DURATION)
|
||||
const eased = 1 - Math.pow(1 - elapsed, 3)
|
||||
const width = Math.round(startWidth + (targetWidth - startWidth) * eased)
|
||||
mainWindow.setBounds({ x: rightEdge - width, y, width, height: WINDOW_HEIGHT })
|
||||
|
||||
if (elapsed === 1) clearInterval(dockAnimationTimer)
|
||||
}, 16)
|
||||
}
|
||||
|
||||
function dockAtRightEdge() {
|
||||
const bounds = mainWindow.getBounds()
|
||||
const { workArea } = screen.getDisplayMatching(bounds)
|
||||
const rightEdge = workArea.x + workArea.width
|
||||
if (bounds.x + bounds.width < rightEdge - DOCK_TRIGGER_DISTANCE) return
|
||||
isDocked = true
|
||||
setDockCollapsed(true)
|
||||
}
|
||||
|
||||
function createTrayImage() {
|
||||
const size = 40
|
||||
const bitmap = Buffer.alloc(size * size * 4)
|
||||
|
||||
function isInsideRoundedRect(x, y, left, top, right, bottom, radius) {
|
||||
const nearestX = Math.max(left + radius, Math.min(x, right - radius))
|
||||
const nearestY = Math.max(top + radius, Math.min(y, bottom - radius))
|
||||
return Math.hypot(x - nearestX, y - nearestY) <= radius
|
||||
}
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const offset = (y * size + x) * 4
|
||||
const isTile = isInsideRoundedRect(x + 0.5, y + 0.5, 4, 4, 36, 36, 9)
|
||||
const isTallBar = isInsideRoundedRect(x + 0.5, y + 0.5, 13, 12, 18, 29, 2.5)
|
||||
const isShortBar = isInsideRoundedRect(x + 0.5, y + 0.5, 23, 19, 28, 29, 2.5)
|
||||
|
||||
if (isTile) {
|
||||
// Bitmap pixels use BGRA order. This matches the widget's cobalt-blue identity.
|
||||
bitmap[offset] = 246
|
||||
bitmap[offset + 1] = 87
|
||||
bitmap[offset + 2] = 49
|
||||
bitmap[offset + 3] = 255
|
||||
}
|
||||
|
||||
if (isTallBar || isShortBar) {
|
||||
bitmap[offset] = 255
|
||||
bitmap[offset + 1] = 255
|
||||
bitmap[offset + 2] = 255
|
||||
bitmap[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nativeImage
|
||||
.createFromBitmap(bitmap, { width: size, height: size, scaleFactor: 1 })
|
||||
.resize({ width: 20, height: 20, quality: 'best' })
|
||||
}
|
||||
|
||||
function showWindow() {
|
||||
if (!mainWindow) return
|
||||
mainWindow.showInactive()
|
||||
mainWindow.moveTop()
|
||||
}
|
||||
|
||||
function trayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{ label: '重新读取', click: () => usageReader.refresh() },
|
||||
{
|
||||
label: '始终置顶',
|
||||
type: 'checkbox',
|
||||
checked: mainWindow?.isAlwaysOnTop() ?? true,
|
||||
click: ({ checked }) => mainWindow?.setAlwaysOnTop(checked, 'floating'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
...initialBounds(),
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
show: false,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow.setAlwaysOnTop(true, 'floating')
|
||||
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: false })
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
showWindow()
|
||||
usageReader.refresh()
|
||||
})
|
||||
|
||||
mainWindow.webContents.once('did-finish-load', async () => {
|
||||
if (screenshotPath) {
|
||||
showWindow()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
mainWindow.webContents.invalidate()
|
||||
const image = await mainWindow.webContents.capturePage()
|
||||
writeFileSync(path.resolve(screenshotPath), image.toPNG())
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('move', saveWindowState)
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('context-menu', () => trayMenu().popup({ window: mainWindow }))
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(createTrayImage())
|
||||
tray.setToolTip('Codex Halo · 本地额度仪表')
|
||||
tray.setContextMenu(trayMenu())
|
||||
tray.on('double-click', showWindow)
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = screenshotPath || app.requestSingleInstanceLock()
|
||||
if (!hasSingleInstanceLock) app.quit()
|
||||
|
||||
app.on('second-instance', showWindow)
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
clearInterval(dockAnimationTimer)
|
||||
usageReader?.stop()
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const sessionsPath = path.join(os.homedir(), '.codex', 'sessions')
|
||||
usageReader = new UsageReader(sessionsPath)
|
||||
usageReader.on('snapshot', (snapshot) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
resizeForSnapshot(snapshot)
|
||||
mainWindow.webContents.send('usage:snapshot', snapshot)
|
||||
}
|
||||
})
|
||||
usageReader.start()
|
||||
|
||||
ipcMain.handle('usage:get', () => usageReader.readLatest())
|
||||
ipcMain.handle('usage:refresh', () => usageReader.refresh())
|
||||
ipcMain.on('window:drag-start', (event, point) => {
|
||||
if (event.sender !== mainWindow?.webContents) return
|
||||
clearTimeout(dockTimer)
|
||||
if (isDocked) setDockCollapsed(false, false)
|
||||
isDocked = false
|
||||
isDockCollapsed = false
|
||||
const bounds = mainWindow.getBounds()
|
||||
dragState = { pointerX: point.x, pointerY: point.y, windowX: bounds.x, windowY: bounds.y }
|
||||
})
|
||||
ipcMain.on('window:drag-move', (event, point) => {
|
||||
if (event.sender !== mainWindow?.webContents || !dragState) return
|
||||
mainWindow.setPosition(
|
||||
Math.round(dragState.windowX + point.x - dragState.pointerX),
|
||||
Math.round(dragState.windowY + point.y - dragState.pointerY),
|
||||
)
|
||||
})
|
||||
ipcMain.on('window:drag-end', () => {
|
||||
dragState = null
|
||||
dockAtRightEdge()
|
||||
})
|
||||
ipcMain.on('window:dock-expand', (event) => {
|
||||
if (event.sender !== mainWindow?.webContents || !isDocked) return
|
||||
clearTimeout(dockTimer)
|
||||
setDockCollapsed(false)
|
||||
})
|
||||
ipcMain.on('window:dock-collapse', (event) => {
|
||||
if (event.sender !== mainWindow?.webContents || !isDocked || dragState) return
|
||||
clearTimeout(dockTimer)
|
||||
dockTimer = setTimeout(() => setDockCollapsed(true), 180)
|
||||
})
|
||||
|
||||
createWindow()
|
||||
if (!screenshotPath) createTray()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// The tray owns the application lifecycle on Windows.
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('codexHalo', {
|
||||
getUsage: () => ipcRenderer.invoke('usage:get'),
|
||||
refreshUsage: () => ipcRenderer.invoke('usage:refresh'),
|
||||
startWindowDrag: (point) => ipcRenderer.send('window:drag-start', point),
|
||||
moveWindowDrag: (point) => ipcRenderer.send('window:drag-move', point),
|
||||
endWindowDrag: () => ipcRenderer.send('window:drag-end'),
|
||||
expandDock: () => ipcRenderer.send('window:dock-expand'),
|
||||
collapseDock: () => ipcRenderer.send('window:dock-collapse'),
|
||||
onUsage: (callback) => {
|
||||
const listener = (_event, snapshot) => callback(snapshot)
|
||||
ipcRenderer.on('usage:snapshot', listener)
|
||||
return () => ipcRenderer.removeListener('usage:snapshot', listener)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { closeSync, existsSync, fstatSync, openSync, readSync, readdirSync, statSync, watch } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const SHORT_WINDOW_MINUTES = 300
|
||||
export const WEEK_WINDOW_MINUTES = 10080
|
||||
|
||||
const MAX_FILES_TO_SCAN = 160
|
||||
const TAIL_BYTES_PER_FILE = 2 * 1024 * 1024
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function parseUsageLine(line, sourceFile = '') {
|
||||
if (!line.includes('"rate_limits"') || !line.includes('"token_count"')) return []
|
||||
|
||||
try {
|
||||
const root = JSON.parse(line)
|
||||
if (root?.type !== 'event_msg' || root?.payload?.type !== 'token_count') return []
|
||||
|
||||
const observedAt = Number.isNaN(Date.parse(root.timestamp))
|
||||
? Date.now()
|
||||
: Date.parse(root.timestamp)
|
||||
|
||||
return ['primary', 'secondary'].flatMap((key) => {
|
||||
const window = root.payload.rate_limits?.[key]
|
||||
const windowMinutes = Number(window?.window_minutes)
|
||||
const usedPercent = Number(window?.used_percent)
|
||||
const resetsAtSeconds = Number(window?.resets_at)
|
||||
|
||||
if (![SHORT_WINDOW_MINUTES, WEEK_WINDOW_MINUTES].includes(windowMinutes)) return []
|
||||
if (![usedPercent, resetsAtSeconds].every(Number.isFinite)) return []
|
||||
|
||||
return [{
|
||||
windowMinutes,
|
||||
usedPercent: clamp(usedPercent, 0, 100),
|
||||
remainingPercent: clamp(100 - usedPercent, 0, 100),
|
||||
resetsAt: resetsAtSeconds * 1000,
|
||||
observedAt,
|
||||
sourceFile,
|
||||
}]
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function collectRolloutFiles(rootPath) {
|
||||
const results = []
|
||||
const pending = [rootPath]
|
||||
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()
|
||||
let entries
|
||||
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) pending.push(fullPath)
|
||||
else if (entry.isFile() && /^rollout-.*\.jsonl$/i.test(entry.name)) results.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.map((filePath) => {
|
||||
try {
|
||||
return { filePath, modifiedAt: statSync(filePath).mtimeMs }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
||||
.slice(0, MAX_FILES_TO_SCAN)
|
||||
}
|
||||
|
||||
function readFileTail(filePath) {
|
||||
let descriptor
|
||||
|
||||
try {
|
||||
descriptor = openSync(filePath, 'r')
|
||||
const size = fstatSync(descriptor).size
|
||||
const start = Math.max(0, size - TAIL_BYTES_PER_FILE)
|
||||
const length = size - start
|
||||
const buffer = Buffer.alloc(length)
|
||||
readSync(descriptor, buffer, 0, length, start)
|
||||
|
||||
const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean)
|
||||
if (start > 0) lines.shift()
|
||||
return lines
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
export function readLatestUsage(sessionsPath) {
|
||||
if (!existsSync(sessionsPath)) {
|
||||
return {
|
||||
shortWindow: null,
|
||||
weekWindow: null,
|
||||
status: '未找到 Codex 会话目录',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
let shortWindow = null
|
||||
let weekWindow = null
|
||||
|
||||
for (const { filePath, modifiedAt } of collectRolloutFiles(sessionsPath)) {
|
||||
for (const line of readFileTail(filePath)) {
|
||||
for (const snapshot of parseUsageLine(line, filePath)) {
|
||||
if (snapshot.windowMinutes === SHORT_WINDOW_MINUTES &&
|
||||
(!shortWindow || snapshot.observedAt > shortWindow.observedAt)) {
|
||||
shortWindow = snapshot
|
||||
}
|
||||
|
||||
if (snapshot.windowMinutes === WEEK_WINDOW_MINUTES &&
|
||||
(!weekWindow || snapshot.observedAt > weekWindow.observedAt)) {
|
||||
weekWindow = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shortWindow && weekWindow &&
|
||||
modifiedAt < shortWindow.observedAt && modifiedAt < weekWindow.observedAt) break
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (shortWindow?.resetsAt <= now) shortWindow = null
|
||||
if (weekWindow?.resetsAt <= now) weekWindow = null
|
||||
|
||||
return {
|
||||
shortWindow,
|
||||
weekWindow,
|
||||
status: shortWindow || weekWindow ? '已从本地日志同步' : '本地日志中暂无额度快照',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export class UsageReader extends EventEmitter {
|
||||
constructor(sessionsPath) {
|
||||
super()
|
||||
this.sessionsPath = sessionsPath
|
||||
this.fileWatcher = null
|
||||
this.refreshTimer = null
|
||||
this.safetyTimer = null
|
||||
}
|
||||
|
||||
readLatest() {
|
||||
return readLatestUsage(this.sessionsPath)
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const snapshot = this.readLatest()
|
||||
this.emit('snapshot', snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
requestRefresh() {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = setTimeout(() => this.refresh(), 250)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (existsSync(this.sessionsPath)) {
|
||||
try {
|
||||
this.fileWatcher = watch(this.sessionsPath, { recursive: true }, (_event, fileName) => {
|
||||
if (!fileName || /(^|[\\/])rollout-.*\.jsonl$/i.test(fileName)) this.requestRefresh()
|
||||
})
|
||||
this.fileWatcher.on('error', () => this.requestRefresh())
|
||||
} catch {
|
||||
this.fileWatcher = null
|
||||
}
|
||||
}
|
||||
|
||||
this.safetyTimer = setInterval(() => this.requestRefresh(), 15_000)
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearTimeout(this.refreshTimer)
|
||||
clearInterval(this.safetyTimer)
|
||||
this.fileWatcher?.close()
|
||||
this.fileWatcher = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user