import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron' import { appendFileSync, mkdirSync, readFileSync, realpathSync } 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' import { DesktopRunnerManager } from './runner-manager.mjs' import { DesktopPreferences } from './preferences.mjs' import { DesktopCredentials } from './credentials.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, runnerManager, 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 preferences = new DesktopPreferences(home) let startHidden = false 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 }) => { try { if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url) else safeExternal(url) } catch { /* Invalid navigation is denied. */ } 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', () => { if (!startHidden) 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 (async()=>{ try{await runnerManager?.stop();await manager?.stop();quitting=true;app.quit()} catch(error){ log(error.message) const choice=await dialog.showMessageBox(window,{type:'warning',message:'服务清理尚未完成',detail:error.message,buttons:['继续运行','仍然退出'],defaultId:0,cancelId:0}) if(choice.response===1){quitting=true;app.quit()}else{closing=false;timer=setInterval(()=>{void manager.check()},10000);timer.unref()} } })() }) app.whenReady().then(async () => { await preferences.load() startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8')) app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`}) 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' }) }, }) const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href) mkdirSync(home,{recursive:true,mode:0o700}) const credentials=new DesktopCredentials({home:realpathSync(home),helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result}) runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration, encrypt:value=>credentials.encrypt(value), decrypt:bytes=>credentials.decrypt(bytes), onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message}, fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{HOME:homedir(),PATH:[join(homedir(),'.local/bin'),join(homedir(),'.orbstack/bin'),'/opt/homebrew/bin','/usr/local/bin',process.env.PATH||'/usr/bin:/bin'].join(':'),NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}), }) async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}} async function importRunner(){ const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]}) if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0])) } 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) }, { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin, click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} }, { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin, click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} }, { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`当前服务:${manager.state.version||'尚未就绪'}${manager.state.external?'(已有外部服务)':'(App 托管)'}\n\n更新:先停止任务并退出 App,备份数据目录,保留旧 App,再用经过签名验证的新 App 替换。新 App 打开后核对服务版本及任务状态。\n\n回退:退出新 App,恢复旧 App;若版本包含数据迁移,同时恢复更新前的数据备份。App 不会自动替换已有外部服务。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) }, { type: 'separator' }, { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' }, { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] }, {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'}, {id:'runner-import',label:'导入节点配置…',click:importRunner}, {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())}, {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]}, { 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)) if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)}) }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() }) }