Files

345 lines
10 KiB
JavaScript
Raw Permalink Normal View History

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.
})