|
|
@@ -0,0 +1,132 @@
|
|
|
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences } from 'electron'
|
|
|
+import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
|
|
|
+import { join, dirname } from 'node:path'
|
|
|
+import { homedir } from 'node:os'
|
|
|
+import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
+import { DesktopServiceManager } from './service-manager.mjs'
|
|
|
+
|
|
|
+const desktopRoot = dirname(fileURLToPath(import.meta.url))
|
|
|
+const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
|
|
|
+// Fixture identity also isolates Chromium cookies, window state and the app lock.
|
|
|
+if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
|
|
|
+app.setName('夭夭')
|
|
|
+if (!app.requestSingleInstanceLock()) { app.quit() }
|
|
|
+else {
|
|
|
+ let window, tray, manager, quitting = false, closing = false, timer
|
|
|
+ const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
|
|
|
+ const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
|
|
|
+ const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
|
|
|
+ mkdirSync(logRoot, { recursive: true })
|
|
|
+ const logFile = join(logRoot, 'server.log')
|
|
|
+ const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
|
|
|
+ const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
|
|
|
+ const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
|
|
|
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
|
|
|
+
|
|
|
+ const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
|
|
|
+ ipcMain.handle('desktop:status', event => {
|
|
|
+ if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
|
|
|
+ return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…' }
|
|
|
+ })
|
|
|
+ ipcMain.handle('desktop:retry', async event => {
|
|
|
+ if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
|
|
|
+ manager.restarts = []
|
|
|
+ try { await manager.start(); return true } catch { return false }
|
|
|
+ })
|
|
|
+ ipcMain.handle('desktop:logs', event => {
|
|
|
+ if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
|
|
|
+ shell.showItemInFolder(logFile)
|
|
|
+ })
|
|
|
+
|
|
|
+ function show() {
|
|
|
+ if (!window || window.isDestroyed()) createWindow()
|
|
|
+ window.show(); window.focus()
|
|
|
+ }
|
|
|
+ function safeExternal(url) {
|
|
|
+ try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
|
|
|
+ catch { /* No arbitrary file, shell or custom URL dispatch. */ }
|
|
|
+ }
|
|
|
+ function createWindow() {
|
|
|
+ window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
|
|
|
+ title: '夭夭', backgroundColor: '#f7f7f7',
|
|
|
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
|
|
|
+ webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
|
|
|
+ window.webContents.setWindowOpenHandler(({ url }) => { safeExternal(url); return { action: 'deny' } })
|
|
|
+ const guardNavigation = (event, url) => {
|
|
|
+ if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
|
|
|
+ event.preventDefault(); safeExternal(url)
|
|
|
+ }
|
|
|
+ window.webContents.on('will-navigate', guardNavigation)
|
|
|
+ window.webContents.on('will-redirect', guardNavigation)
|
|
|
+ window.webContents.on('will-attach-webview', event => event.preventDefault())
|
|
|
+ const trustedService = (contents, url) => {
|
|
|
+ try { return contents === window?.webContents && !!manager?.state.url && new URL(url).origin === manager.state.url }
|
|
|
+ catch { return false }
|
|
|
+ }
|
|
|
+ window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
|
|
|
+ trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
|
|
|
+ window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
|
|
|
+ if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
|
|
|
+ if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
|
|
|
+ void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
|
|
|
+ } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
|
|
|
+ })
|
|
|
+ window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
|
|
|
+ window.once('ready-to-show', () => window.show())
|
|
|
+ if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
|
|
|
+ else void window.loadFile(join(desktopRoot, 'boot.html'))
|
|
|
+ }
|
|
|
+ function stateChanged(state) {
|
|
|
+ log(`${state.phase}: ${state.message}`)
|
|
|
+ tray?.setToolTip(`夭夭 · ${state.message}`)
|
|
|
+ if (!window || window.isDestroyed()) return
|
|
|
+ if (state.phase === 'ready') {
|
|
|
+ const current = window.webContents.getURL()
|
|
|
+ if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
|
|
|
+ } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
|
|
|
+ }
|
|
|
+ app.on('second-instance', show)
|
|
|
+ app.on('activate', show)
|
|
|
+ app.on('window-all-closed', () => {})
|
|
|
+ app.on('before-quit', event => {
|
|
|
+ if (quitting) return
|
|
|
+ event.preventDefault()
|
|
|
+ if (closing) return
|
|
|
+ closing = true; clearInterval(timer)
|
|
|
+ void manager?.stop().catch(error => log(error.message)).finally(() => { quitting = true; app.quit() })
|
|
|
+ })
|
|
|
+ app.whenReady().then(async () => {
|
|
|
+ const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
|
|
|
+ manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
|
|
|
+ fork: ({ home, port }) => {
|
|
|
+ const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
|
|
|
+ PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
|
|
|
+ HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
|
|
|
+ HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
|
|
|
+ HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
|
|
|
+ HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
|
|
|
+ delete env.ELECTRON_RUN_AS_NODE
|
|
|
+ delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
|
|
|
+ return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
|
|
|
+ },
|
|
|
+ })
|
|
|
+ Menu.setApplicationMenu(Menu.buildFromTemplate([
|
|
|
+ { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
|
|
|
+ { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
|
|
|
+ { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' },
|
|
|
+ { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
|
|
|
+ { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
|
|
|
+ { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
|
|
|
+ ]))
|
|
|
+ const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
|
|
|
+ icon.setTemplateImage(true)
|
|
|
+ tray = new Tray(icon)
|
|
|
+ tray.setToolTip('夭夭')
|
|
|
+ tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭', click: () => app.quit() }]))
|
|
|
+ tray.on('click', show)
|
|
|
+ createWindow()
|
|
|
+ powerMonitor.on('resume', () => { void manager.check() })
|
|
|
+ timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
|
|
|
+ await manager.start().catch(error => log(error.message))
|
|
|
+ }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
|
|
|
+}
|