import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { DesktopAccountInfo, DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, DesktopRuntimeSessionSyncRequest, } from '@geo/shared-types' import { isAIPlatformId } from '@geo/shared-types' import { shell } from 'electron' import type { BrowserWindow as ElectronBrowserWindow, WebContents as ElectronWebContents, IpcMainInvokeEvent, } from 'electron/main' import { BrowserWindow, Menu, app, ipcMain, nativeTheme } from 'electron/main' import { bindPublishAccount, openPublishAccountConsole } from './account-binder' import { initAccountHealth } from './account-health' import { applyDesktopHealthIndicatorFromSnapshot, createDesktopHealthIcon, startDesktopHealthIndicator, syncDesktopHealthIndicator, } from './app-issue-indicator' import { getDesktopAppSettings, hasShownBackgroundBalloon, initDesktopAppSettings, markBackgroundBalloonShown, setDesktopAppSetting, type DesktopAppSettingKey, } from './app-settings' import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' import { initDesktopBugReporter, submitManualDesktopBugReport, syncDesktopBugReporterSession, } from './bug-reporter' import { defaultDesktopUpdateChannel, startDesktopClientUpdate, type DesktopClientUpdateProgressEvent, } from './client-updater' import { resolveDesktopClientID, resolveDesktopDeviceInfo } from './device-id' import { clearSavedLoginCredentials, getSavedLoginCredentials, saveLoginCredentials, } from './login-credentials' import { installObservedGlobalFetch, registerObservedRequestRendererTarget, } from './network-observer' import { getPlaywrightCDPPort, preparePlaywrightCDPPort, startHiddenPlaywrightReaper } from './playwright-cdp' import { initProcessMetricsSampler } from './process-metrics' import { registerRendererDevtoolsProxyTarget } from './renderer-devtools-proxy' import { getRuntimeControllerSnapshot, noteRuntimeAccountBound, refreshRuntimeAccounts, releaseRuntimeSession, requestRuntimeAccountProbe, syncRuntimeSession, unbindRuntimeAccount, } from './runtime-controller' import { onRuntimeInvalidated } from './runtime-events' import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-snapshot' import { initScheduler } from './scheduler' import { initSessionRegistry } from './session-registry' import { initSingleInstance } from './single-instance' import { cleanDesktopStorage, getDesktopStorageSnapshot, startStorageCleanupScheduler, } from './storage-cleaner' import { cancelDesktopTask, checkDesktopClientRelease, initTransport, listDesktopPublishTasks, resolveDesktopClientReleaseDownloadURL, retryDesktopPublishTask, rotateDesktopClient, } from './transport/api-client' import { initTray, showTrayBalloon } from './tray' import { STANDARD_USER_AGENT } from './user-agent' import { startHotViewReaper } from './view-pool' // In dev (no packaged Info.plist / NSIS metadata) Electron falls back to the // generic "Electron" name, so the macOS menu bar and Windows task switcher // show "Electron". Pin the dev-time app name to the product brand. Packaged // builds ignore this because Info.plist `CFBundleName` (driven by // electron-builder `productName`) takes precedence. app.setName('ShengxinPush') // The macOS "About" panel pulls from a separate options bag — `setName` alone // leaves it as "Electron 41.x". Set explicit copy here so the dev About dialog // matches the brand. Packaged macOS again wins via Info.plist. app.setAboutPanelOptions({ applicationName: 'ShengxinPush', applicationVersion: app.getVersion(), version: app.getVersion(), copyright: 'Copyright © 2026 shengxintui.com', website: 'https://shengxintui.com', }) app.commandLine.appendSwitch( 'disable-features', 'PostQuantumKyber,EncryptedClientHello,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn', ) app.commandLine.appendSwitch('disable-quic') app.commandLine.appendSwitch('disable-background-networking') // CDP is required by the hidden Playwright execution pool. Electron only reads // this switch during early startup, so select the port before app.whenReady(). app.userAgentFallback = STANDARD_USER_AGENT // Windows 10+ routes tray.displayBalloon through the Toast Notification API, // which requires a registered AppUserModelID. electron-builder writes this on // the Start Menu shortcut at install time, but we set it explicitly so dev // runs and edge-case installs also fire the balloon reliably. if (process.platform === 'win32') { app.setAppUserModelId('com.geo.rankly.desktop') } const isDevelopmentRuntime = !app.isPackaged const desktopDeepLinkScheme = 'shengxintui' function silencePackagedConsole(): void { if (isDevelopmentRuntime) { return } const noop = () => undefined console.log = noop console.info = noop console.warn = noop console.error = noop console.debug = noop } silencePackagedConsole() initDesktopBugReporter() if (isDevelopmentRuntime) { installObservedGlobalFetch() } console.info('[desktop-main] boot', { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node, ua: STANDARD_USER_AGENT, cdpPort: getPlaywrightCDPPort(), }) process.on('unhandledRejection', (reason) => { console.error('[desktop-main] unhandled rejection', reason) }) process.on('uncaughtException', (error) => { console.error('[desktop-main] uncaught exception', error) }) let mainWindow: ElectronBrowserWindow | null = null let loginWindow: ElectronBrowserWindow | null = null let settingsWindow: ElectronBrowserWindow | null = null let mainRendererContents: ElectronWebContents | null = null let quitReleaseInFlight = false let cdpRecoveryRelaunchInFlight = false let quitAfterSaasLogoutPromise: Promise | null = null let mainWindowCreatePromise: Promise | null = null let loginWindowCreatePromise: Promise | null = null let settingsWindowCreatePromise: Promise | null = null let windowModeSwitchQueue: Promise = Promise.resolve() let clientTokenRotatePromise: Promise | null = null let canHandleDesktopDeepLinks = false const pendingDesktopDeepLinks: string[] = [] const forceCloseWindows = new WeakSet() const appWindowModes = new WeakMap() const maxCDPRecoveryRelaunchesPerWindow = 3 const cdpRecoveryRelaunchWindowMs = 10 * 60_000 const cdpRecoveryRelaunchDelayMs = 1_500 const cdpFatalArg = '--geo-cdp-fatal' type WindowMode = 'login' | 'main' type RendererWindowMode = WindowMode | 'settings' type WindowModeSource = 'auth-state' | 'login' | 'logout' interface WindowSizePreset { width: number height: number minWidth: number minHeight: number resizable: boolean } interface WindowModeRequest { source?: WindowModeSource animate?: boolean } const WINDOW_READY_TIMEOUT_MS = 1200 const WINDOW_SIZES: Record = { login: { width: 340, height: 540, minWidth: 340, minHeight: 540, resizable: false }, main: { width: 1420, height: 960, minWidth: 900, minHeight: 600, resizable: true }, } const SETTINGS_WINDOW_SIZE: WindowSizePreset = { width: 920, height: 640, minWidth: 760, minHeight: 520, resizable: true, } const TITLE_BAR_OVERLAY_HEIGHT = 24 function appTitleBarStyle(): 'hidden' | 'hiddenInset' { return process.platform === 'darwin' ? 'hiddenInset' : 'hidden' } function appTitleBarOverlay( color: string, ): { color: string; symbolColor: string; height: number } | undefined { if (process.platform === 'darwin') { return undefined } return { color, symbolColor: nativeTheme.shouldUseDarkColors ? '#f8fafc' : '#111827', height: TITLE_BAR_OVERLAY_HEIGHT, } } function suppressNativeWindowMenu(window?: ElectronBrowserWindow): void { if (process.platform === 'darwin') { return } Menu.setApplicationMenu(null) window?.setMenu(null) window?.setMenuBarVisibility(false) } function windowModePath(): string { return join(app.getPath('userData'), 'window-mode.json') } function readPersistedWindowMode(): WindowMode { try { const raw = readFileSync(windowModePath(), 'utf8') const data = JSON.parse(raw) as { mode?: unknown } if (data?.mode === 'main' || data?.mode === 'login') { return data.mode } } catch { // First run, missing file, or corrupt — fall through to default. } return 'login' } function persistWindowMode(mode: WindowMode): void { try { writeFileSync(windowModePath(), JSON.stringify({ mode }), 'utf8') } catch (err) { console.warn('[desktop-main] persist window mode failed', err) } } let currentWindowMode: WindowMode = 'login' function normalizeWindowModeRequest(input: unknown): WindowModeRequest { if (!input || typeof input !== 'object') { return {} } const candidate = input as { source?: unknown; animate?: unknown } return { source: candidate.source === 'login' || candidate.source === 'logout' || candidate.source === 'auth-state' ? candidate.source : undefined, animate: candidate.animate === true, } } function currentMainWindow(): ElectronBrowserWindow | null { if (!mainWindow) { return null } if (mainWindow.isDestroyed()) { mainWindow = null return null } return mainWindow } function currentLoginWindow(): ElectronBrowserWindow | null { if (!loginWindow) { return null } if (loginWindow.isDestroyed()) { loginWindow = null return null } return loginWindow } function currentSettingsWindow(): ElectronBrowserWindow | null { if (!settingsWindow) { return null } if (settingsWindow.isDestroyed()) { settingsWindow = null return null } return settingsWindow } function currentActiveWindow(): ElectronBrowserWindow | null { return currentMainWindow() ?? currentLoginWindow() } function rendererModeFromWindow(window: ElectronBrowserWindow): RendererWindowMode | null { if (window.isDestroyed()) { return null } const knownMode = appWindowModes.get(window) if (knownMode) { return knownMode } try { const url = new URL(window.webContents.getURL()) const mode = url.searchParams.get('window') return mode === 'login' || mode === 'main' || mode === 'settings' ? mode : null } catch { return null } } function retireWindowAfterIpcReturn(window: ElectronBrowserWindow): void { if (window.isDestroyed()) { return } forceCloseWindows.add(window) if (window.isVisible()) { window.hide() } setTimeout(() => { if (!window.isDestroyed()) { window.destroy() } }, 50) } function retireInactiveAppWindows(mode: WindowMode, target: ElectronBrowserWindow): void { for (const candidate of BrowserWindow.getAllWindows()) { if (candidate === target || candidate.isDestroyed()) { continue } const candidateMode = rendererModeFromWindow(candidate) if ( (mode === 'main' && (candidateMode === 'login' || candidateMode === 'main')) || (mode === 'login' && (candidateMode === 'login' || candidateMode === 'main' || candidateMode === 'settings')) ) { retireWindowAfterIpcReturn(candidate) } } } async function waitForWindowReady(window: ElectronBrowserWindow): Promise { if (window.isDestroyed() || !window.webContents.isLoading()) { return } await new Promise((resolve) => { let settled = false let timeout: ReturnType | null = null const finish = () => { if (settled) { return } settled = true if (timeout) { clearTimeout(timeout) } window.off('ready-to-show', finish) window.webContents.off('did-finish-load', finish) window.webContents.off('did-fail-load', finish) resolve() } timeout = setTimeout(finish, WINDOW_READY_TIMEOUT_MS) window.once('ready-to-show', finish) window.webContents.once('did-finish-load', finish) window.webContents.once('did-fail-load', finish) }) } function captureRuntimeSnapshot() { const snapshot = createRuntimeSnapshot() applyDesktopHealthIndicatorFromSnapshot(snapshot, currentMainWindow()) return snapshot } function captureRuntimeAccountSnapshot(accountId: string) { return createRuntimeAccountSnapshot(accountId) } async function clearRendererDesktopSession(window: ElectronBrowserWindow | null): Promise { if (!window || window.isDestroyed()) { return } await window.webContents .executeJavaScript("window.localStorage.removeItem('geo.desktop.session.v1')", true) .catch((error) => { console.warn('[desktop-main] clear renderer desktop session failed', { message: error instanceof Error ? error.message : String(error), }) }) } async function clearRendererDesktopSessions(): Promise { await Promise.all( BrowserWindow.getAllWindows().map((window) => clearRendererDesktopSession(window).catch((error) => { console.warn('[desktop-main] clear window desktop session failed', { message: error instanceof Error ? error.message : String(error), }) }), ), ) } async function ensureMainWindow(): Promise { const existing = currentMainWindow() if (existing) { return existing } if (!mainWindowCreatePromise) { mainWindowCreatePromise = createAppWindow('main') .then((window) => { mainWindow = window return window }) .finally(() => { mainWindowCreatePromise = null }) } return mainWindowCreatePromise } async function ensureLoginWindow(): Promise { const existing = currentLoginWindow() if (existing) { return existing } if (!loginWindowCreatePromise) { loginWindowCreatePromise = createAppWindow('login') .then((window) => { loginWindow = window return window }) .finally(() => { loginWindowCreatePromise = null }) } return loginWindowCreatePromise } async function ensureSettingsWindow(): Promise { const existing = currentSettingsWindow() if (existing) { return existing } if (!settingsWindowCreatePromise) { settingsWindowCreatePromise = createSettingsWindow() .then((window) => { settingsWindow = window return window }) .finally(() => { settingsWindowCreatePromise = null }) } return settingsWindowCreatePromise } async function ensureWindow(mode: WindowMode): Promise { return mode === 'main' ? ensureMainWindow() : ensureLoginWindow() } async function revealWindow(window: ElectronBrowserWindow): Promise { await waitForWindowReady(window) if (window.isMinimized()) { window.restore() } if (!window.isVisible()) { window.show() } window.focus() app.focus({ steal: true }) } async function revealActiveWindow(): Promise { const window = currentActiveWindow() ?? (await ensureWindow(currentWindowMode)) await revealWindow(window) } function revealActiveWindowSafely(source: string): void { void revealActiveWindow().catch((error) => { console.error('[desktop-main] reveal active window failed', { source, error }) }) } function timeout(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function quitAfterClearingSaasSession(): void { if (quitAfterSaasLogoutPromise) { return } quitAfterSaasLogoutPromise = (async () => { currentWindowMode = 'login' persistWindowMode('login') // Only clear the Shengxintui SaaS session. Bound media/AI account browser // partitions are intentionally preserved so the same account can reuse them // after signing in again. await Promise.race([clearRendererDesktopSessions(), timeout(1000)]) await Promise.race([releaseRuntimeSession(), timeout(1500)]) })() .catch((error) => { console.warn('[desktop-main] quit after clearing saas session failed', { message: error instanceof Error ? error.message : String(error), }) }) .finally(() => { quitReleaseInFlight = true app.quit() }) } function requestPlaywrightCDPRecoveryRelaunch(message: string): void { if (cdpRecoveryRelaunchInFlight) { return } const now = Date.now() const recentRelaunches = process.argv .filter((arg) => arg.startsWith('--geo-cdp-recovery-at=')) .map((arg) => Number.parseInt(arg.split('=')[1] ?? '', 10)) .filter((timestamp) => Number.isFinite(timestamp) && now - timestamp < cdpRecoveryRelaunchWindowMs) if (recentRelaunches.length >= maxCDPRecoveryRelaunchesPerWindow) { console.error('[desktop-main] playwright cdp recovery relaunch suppressed', { message, recentRelaunches: recentRelaunches.length, }) showTrayBalloon( '浏览器自动化通道异常', '客户端已停止采集。请重启客户端或检查安全软件/端口占用。', ) return } cdpRecoveryRelaunchInFlight = true console.warn('[desktop-main] scheduling playwright cdp recovery relaunch', { message, cdpPort: getPlaywrightCDPPort(), }) showTrayBalloon('正在恢复浏览器自动化通道', '客户端将自动重启后台进程,登录状态会保留。') setTimeout(() => { quitReleaseInFlight = true app.relaunch({ args: [ ...process.argv.slice(1).filter((arg) => !arg.startsWith('--geo-cdp-recovery-at=')), ...recentRelaunches.map((timestamp) => `--geo-cdp-recovery-at=${timestamp}`), `--geo-cdp-recovery-at=${now}`, ...(recentRelaunches.length + 1 >= maxCDPRecoveryRelaunchesPerWindow ? [cdpFatalArg] : []), ], }) app.exit(0) }, cdpRecoveryRelaunchDelayMs) } function windowFromIpcEvent(event: IpcMainInvokeEvent): ElectronBrowserWindow | null { const window = BrowserWindow.fromWebContents(event.sender) if (!window || window.isDestroyed()) { return null } return window } async function switchWindowMode( mode: WindowMode, _request: WindowModeRequest = {}, sourceWindow: ElectronBrowserWindow | null = null, ): Promise { currentWindowMode = mode persistWindowMode(mode) const target = await ensureWindow(mode) const previous = sourceWindow && sourceWindow !== target ? sourceWindow : mode === 'main' ? currentLoginWindow() : currentMainWindow() if (!target.isVisible()) { target.center() } await revealWindow(target) if (previous && previous !== target && !previous.isDestroyed()) { retireWindowAfterIpcReturn(previous) } const settings = currentSettingsWindow() if (mode === 'login' && settings) { retireWindowAfterIpcReturn(settings) } retireInactiveAppWindows(mode, target) } function queueWindowModeSwitch( mode: WindowMode, request: WindowModeRequest = {}, sourceWindow: ElectronBrowserWindow | null = null, ): Promise { const nextSwitch = windowModeSwitchQueue .catch(() => undefined) .then(() => switchWindowMode(mode, request, sourceWindow)) windowModeSwitchQueue = nextSwitch.catch(() => undefined) return nextSwitch } async function openSettingsWindow(): Promise { const window = await ensureSettingsWindow() const parent = settingsWindowParent() if (parent && window.getParentWindow() !== parent) { window.setParentWindow(parent) } await revealWindow(window) } function settingsWindowParent(): ElectronBrowserWindow | undefined { if (process.platform !== 'win32') { return undefined } return currentMainWindow() ?? undefined } function rendererURL(): string | null { return process.env.ELECTRON_RENDERER_URL ?? null } function preloadPath(): string { return join(__dirname, '../preload/bridge.cjs') } async function loadRenderer( window: ElectronBrowserWindow, mode: RendererWindowMode, ): Promise { const devServerURL = rendererURL() if (devServerURL) { const url = new URL(devServerURL) url.searchParams.set('window', mode) await window.webContents.loadURL(url.toString()) if (mode === 'main' && isDevelopmentRuntime) { window.webContents.openDevTools({ mode: 'detach' }) } return } await window.webContents.loadFile(join(__dirname, '../renderer/index.html'), { query: { window: mode }, }) } async function mountRendererWebContents( window: ElectronBrowserWindow, mode: RendererWindowMode, ): Promise { const contents = window.webContents contents.setWindowOpenHandler(() => ({ action: 'deny' })) if (mode === 'main') { if (isDevelopmentRuntime) { registerRendererDevtoolsProxyTarget(contents) registerObservedRequestRendererTarget(contents) } mainRendererContents = contents window.on('closed', () => { if (mainRendererContents === contents) { mainRendererContents = null } }) } await loadRenderer(window, mode) } async function createAppWindow(mode: WindowMode): Promise { const initial = WINDOW_SIZES[mode] const backgroundColor = mode === 'login' ? '#eaf2ff' : nativeTheme.shouldUseDarkColors ? '#15191a' : '#f0f2f5' const window = new BrowserWindow({ width: initial.width, height: initial.height, minWidth: initial.minWidth, minHeight: initial.minHeight, show: false, title: '省心推', icon: createDesktopHealthIcon('normal'), titleBarStyle: appTitleBarStyle(), titleBarOverlay: appTitleBarOverlay('#00000000'), trafficLightPosition: { x: 12, y: 14 }, resizable: initial.resizable, minimizable: mode !== 'login', maximizable: initial.resizable, fullscreenable: initial.resizable, autoHideMenuBar: true, backgroundColor, webPreferences: { preload: preloadPath(), sandbox: true, contextIsolation: true, nodeIntegration: false, }, }) appWindowModes.set(window, mode) suppressNativeWindowMenu(window) if (!initial.resizable) { window.setMinimumSize(initial.minWidth, initial.minHeight) window.setMaximumSize(initial.width, initial.height) } window.once('closed', () => { if (mode === 'main' && mainWindow === window) { mainWindow = null } if (mode === 'login' && loginWindow === window) { loginWindow = null } }) window.on('close', (event) => { if ( mode === 'main' && !quitReleaseInFlight && !forceCloseWindows.has(window) && getDesktopAppSettings().keepRunningInBackground ) { event.preventDefault() window.hide() // Windows: notify users once that the app is still running in the tray. // displayBalloon is a no-op on macOS/Linux so the guard is in tray.ts. if (process.platform === 'win32' && !hasShownBackgroundBalloon()) { showTrayBalloon('省心推已最小化到托盘', '应用仍在后台运行,右键托盘图标可退出。') markBackgroundBalloonShown() } } }) window.on('minimize', () => { console.warn('[desktop-main] main window minimize event', { at: new Date().toISOString(), stack: new Error('trace').stack, }) }) window.on('hide', () => { console.warn('[desktop-main] main window hide event', { at: new Date().toISOString(), stack: new Error('trace').stack, }) }) window.on('blur', () => { console.warn('[desktop-main] main window blur event', { at: new Date().toISOString(), visible: window.isVisible(), minimized: window.isMinimized(), }) }) await mountRendererWebContents(window, mode) return window } async function createSettingsWindow(): Promise { const initial = SETTINGS_WINDOW_SIZE const backgroundColor = nativeTheme.shouldUseDarkColors ? '#15191a' : '#f2f4f7' const window = new BrowserWindow({ parent: settingsWindowParent(), width: initial.width, height: initial.height, minWidth: initial.minWidth, minHeight: initial.minHeight, show: false, title: '省心推设置', icon: createDesktopHealthIcon('normal'), titleBarStyle: appTitleBarStyle(), titleBarOverlay: appTitleBarOverlay(backgroundColor), trafficLightPosition: { x: 14, y: 15 }, resizable: initial.resizable, maximizable: initial.resizable, fullscreenable: false, autoHideMenuBar: true, backgroundColor, webPreferences: { preload: preloadPath(), sandbox: true, contextIsolation: true, nodeIntegration: false, }, }) appWindowModes.set(window, 'settings') suppressNativeWindowMenu(window) window.once('closed', () => { if (settingsWindow === window) { settingsWindow = null } }) await mountRendererWebContents(window, 'settings') return window } function ipcErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message || 'ipc_handler_failed' } if (typeof error === 'string') { return error || 'ipc_handler_failed' } if ( error && typeof error === 'object' && 'message' in error && typeof (error as { message?: unknown }).message === 'string' ) { return (error as { message: string }).message || 'ipc_handler_failed' } return 'ipc_handler_failed' } function flattenError(channel: string, error: unknown): Error { const message = ipcErrorMessage(error) console.error('[desktop-main] ipc handler failed', { channel, name: error instanceof Error ? error.name : typeof error, message, stack: error instanceof Error ? error.stack : undefined, }) if (error instanceof Error) { const copy = new Error(message) copy.name = error.name return copy } return new Error(message) } function safeHandle( channel: string, handler: (...args: Args) => Promise | Result, ): void { ipcMain.handle(channel, async (...args) => { try { return await handler(...(args as unknown as Args)) } catch (error) { throw flattenError(channel, error) } }) } function isSafeExternalUrl(url: string): boolean { try { const parsed = new URL(url) return parsed.protocol === 'http:' || parsed.protocol === 'https:' } catch { return false } } function desktopReleasePlatform(): string { switch (process.platform) { case 'darwin': return 'darwin' case 'win32': return 'win32' case 'linux': return 'linux' default: return process.platform } } function desktopReleaseArch(): string { switch (process.arch) { case 'x64': case 'arm64': case 'ia32': return process.arch default: return 'universal' } } async function checkCurrentDesktopClientRelease(): Promise { const snapshot = getRuntimeControllerSnapshot() if (!snapshot.session?.client_token) { throw new Error('desktop_client_not_registered') } return checkDesktopClientRelease({ platform: desktopReleasePlatform(), arch: desktopReleaseArch(), version: getDesktopAppVersion(), channel: snapshot.session.desktop_client?.channel || getDesktopReleaseChannel(), }) } async function resolveCurrentDesktopClientReleaseDownloadURL(): Promise<{ download_url: string }> { const snapshot = getRuntimeControllerSnapshot() if (!snapshot.session?.client_token) { throw new Error('desktop_client_not_registered') } return resolveDesktopClientReleaseDownloadURL({ platform: desktopReleasePlatform(), arch: desktopReleaseArch(), version: getDesktopAppVersion(), channel: snapshot.session.desktop_client?.channel || getDesktopReleaseChannel(), }) } function emitClientUpdateProgress(event: DesktopClientUpdateProgressEvent): void { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { window.webContents.send('desktop:client-update-progress', event) } } } function registerDesktopProtocolClient(): void { if (app.isDefaultProtocolClient(desktopDeepLinkScheme)) { return } if (process.defaultApp && process.execPath) { app.setAsDefaultProtocolClient(desktopDeepLinkScheme, process.execPath, [process.argv[1] ?? '']) return } app.setAsDefaultProtocolClient(desktopDeepLinkScheme) } function extractDesktopDeepLink(argv: string[]): string | null { return argv.find((item) => item.startsWith(`${desktopDeepLinkScheme}://`)) ?? null } function queueDesktopDeepLink(rawUrl: string | null | undefined): void { if (!rawUrl) { return } if (!canHandleDesktopDeepLinks) { pendingDesktopDeepLinks.push(rawUrl) return } handleDesktopDeepLink(rawUrl) } function flushPendingDesktopDeepLinks(): void { canHandleDesktopDeepLinks = true const links = pendingDesktopDeepLinks.splice(0) for (const link of links) { handleDesktopDeepLink(link) } } function handleDesktopDeepLink(rawUrl: string): void { let parsed: URL try { parsed = new URL(rawUrl) } catch { return } if (parsed.protocol !== `${desktopDeepLinkScheme}:` || parsed.hostname !== 'desktop') { return } if ( parsed.pathname === '/workbench' || parsed.searchParams.get('action') === 'open-publish-account-console' ) { const account = { id: parsed.searchParams.get('account_id') ?? '', platform: parsed.searchParams.get('platform') ?? '', platformUid: parsed.searchParams.get('platform_uid') ?? '', displayName: parsed.searchParams.get('display_name') ?? '', } if (!account.id || !account.platform) { return } void openPublishAccountConsole(account).catch((error) => { console.warn('[desktop-deeplink] open publish account console failed', { accountId: account.id, platform: account.platform, message: error instanceof Error ? error.message : String(error), }) }) } } function registerBridgeHandlers(): void { ipcMain.handle('desktop:ping', () => 'pong') ipcMain.handle('desktop:version-info', () => ({ version: getDesktopAppVersion(), channel: getDesktopReleaseChannel(), })) safeHandle('desktop:check-client-release', () => checkCurrentDesktopClientRelease()) safeHandle('desktop:client-release-download-url', () => resolveCurrentDesktopClientReleaseDownloadURL(), ) safeHandle('desktop:quit-app', async () => { app.quit() return null }) safeHandle('desktop:start-client-update', async () => { const snapshot = getRuntimeControllerSnapshot() if (!snapshot.session?.client_token) { throw new Error('desktop_client_not_registered') } return startDesktopClientUpdate({ platform: desktopReleasePlatform(), arch: desktopReleaseArch(), channel: defaultDesktopUpdateChannel(snapshot.session.desktop_client?.channel), notify: emitClientUpdateProgress, beforeInstall: () => { quitReleaseInFlight = true }, }) }) ipcMain.handle('desktop:device-info', () => resolveDesktopDeviceInfo()) safeHandle( 'desktop:client-id', (_event, scope: { tenant_id?: unknown; workspace_id?: unknown; user_id?: unknown }) => resolveDesktopClientID({ tenant_id: typeof scope?.tenant_id === 'number' ? scope.tenant_id : 0, workspace_id: typeof scope?.workspace_id === 'number' ? scope.workspace_id : 0, user_id: typeof scope?.user_id === 'number' ? scope.user_id : 0, }), ) ipcMain.handle('desktop:runtime-snapshot', () => captureRuntimeSnapshot()) ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) => captureRuntimeAccountSnapshot(accountId), ) safeHandle( 'desktop:submit-bug-report', async ( _event, input: { title?: string description?: string severity?: string }, ) => submitManualDesktopBugReport(input ?? {}), ) safeHandle('desktop:bind-publish-account', async (_event, platformId: string) => { const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo noteRuntimeAccountBound(account) void refreshRuntimeAccounts().catch((error) => { console.warn('[desktop-bind] background account refresh failed', { message: error instanceof Error ? error.message : String(error), }) }) return account }) safeHandle('desktop:refresh-runtime-accounts', async () => { await refreshRuntimeAccounts() return null }) safeHandle('desktop:probe-runtime-account', async (_event, accountId: string) => { await requestRuntimeAccountProbe(accountId) return captureRuntimeAccountSnapshot(accountId) }) safeHandle( 'desktop:open-publish-account-console', async ( _event, account: { id: string; platform: string; platformUid: string; displayName: string }, ) => { await openPublishAccountConsole(account) if (isAIPlatformId(account.platform)) { return null } void requestRuntimeAccountProbe(account.id, { account, force: true, }).catch((error) => { console.warn('[desktop-console] background account probe failed', { accountId: account.id, platform: account.platform, message: error instanceof Error ? error.message : String(error), }) }) return null }, ) safeHandle( 'desktop:unbind-publish-account', async (_event, accountId: string, syncVersion: number) => { await unbindRuntimeAccount(accountId, syncVersion) return null }, ) safeHandle( 'desktop:list-publish-tasks', async (_event, params?: { page?: number; page_size?: number; title?: string }) => { return listDesktopPublishTasks(params) }, ) safeHandle('desktop:retry-publish-task', async (_event, taskId: string) => { return retryDesktopPublishTask(taskId) }) safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => { return cancelDesktopTask(taskId, { reason: 'user_cancelled_publish' }) }) safeHandle('desktop:open-external-url', async (_event, url: string) => { if (!isSafeExternalUrl(url)) { throw new Error('unsupported_external_url') } await shell.openExternal(url) return null }) safeHandle('desktop:open-settings-window', async () => { await openSettingsWindow() return null }) ipcMain.handle('desktop:get-app-settings', () => getDesktopAppSettings()) ipcMain.handle('desktop:get-login-credentials', () => getSavedLoginCredentials()) safeHandle( 'desktop:save-login-credentials', (_event, credentials: { identifier?: unknown; password?: unknown }) => saveLoginCredentials({ identifier: typeof credentials?.identifier === 'string' ? credentials.identifier : '', password: typeof credentials?.password === 'string' ? credentials.password : '', }), ) ipcMain.handle('desktop:clear-login-credentials', () => clearSavedLoginCredentials()) safeHandle( 'desktop:set-app-setting', async (_event, key: DesktopAppSettingKey, value: boolean) => { if ( key !== 'openAtLogin' && key !== 'keepRunningInBackground' && key !== 'autoCleanStorageWeekly' ) { throw new Error('unsupported_app_setting') } return setDesktopAppSetting(key, value) }, ) safeHandle('desktop:storage-snapshot', () => getDesktopStorageSnapshot()) safeHandle('desktop:clean-storage', () => cleanDesktopStorage()) safeHandle( 'desktop:runtime-session-sync', (_event, session: DesktopRuntimeSessionSyncRequest | null) => { syncRuntimeSession(session) syncDesktopBugReporterSession(session) return null }, ) safeHandle('desktop:runtime-session-rotate-client-token', async () => { if (!clientTokenRotatePromise) { clientTokenRotatePromise = rotateDesktopClient() .then((rotated) => { const currentSession = getRuntimeControllerSnapshot().session if ( currentSession?.mode === 'authenticated' && currentSession.desktop_client?.id === rotated.client.id ) { syncRuntimeSession({ ...currentSession, client_token: rotated.client_token, desktop_client: rotated.client, }) } return rotated }) .finally(() => { clientTokenRotatePromise = null }) } return clientTokenRotatePromise }) safeHandle('desktop:runtime-session-release', async (_event, revoke?: boolean) => { if (revoke) { currentWindowMode = 'login' persistWindowMode('login') await clearRendererDesktopSessions() } await releaseRuntimeSession({ revoke: Boolean(revoke) }) return null }) safeHandle( 'desktop:set-window-mode', async (event: IpcMainInvokeEvent, mode: WindowMode, request?: unknown) => { if (mode === 'login' || mode === 'main') { await queueWindowModeSwitch( mode, normalizeWindowModeRequest(request), windowFromIpcEvent(event), ) } return null }, ) } const hasSingleInstanceLock = initSingleInstance((argv) => { revealActiveWindowSafely('single-instance') queueDesktopDeepLink(extractDesktopDeepLink(argv)) }) if (!hasSingleInstanceLock) { console.info('[desktop-main] another instance is already running; exiting') app.exit(0) } else { preparePlaywrightCDPPort() .then((selectedPlaywrightCDPPort) => { app.commandLine.appendSwitch('remote-debugging-port', String(selectedPlaywrightCDPPort)) return app.whenReady() }) .then(async () => { console.info('[desktop-main] playwright cdp port selected', { cdpPort: getPlaywrightCDPPort(), }) suppressNativeWindowMenu() registerDesktopProtocolClient() currentWindowMode = readPersistedWindowMode() initDesktopAppSettings() registerBridgeHandlers() await initSessionRegistry() initAccountHealth() initTransport() initScheduler() startStorageCleanupScheduler() initProcessMetricsSampler() startHotViewReaper() startHiddenPlaywrightReaper() onRuntimeInvalidated((event) => { syncDesktopHealthIndicator(currentMainWindow()) if (event.reason === 'playwright-cdp-fatal') { showTrayBalloon( '浏览器自动化组件不可用', event.message ?? '客户端已停止采集,请重启客户端或检查安全软件/端口占用。', ) } if (event.reason === 'playwright-cdp-recovery') { requestPlaywrightCDPRecoveryRelaunch(event.message ?? 'playwright_cdp_endpoint_unavailable') } if (!mainRendererContents || mainRendererContents.isDestroyed()) { return } mainRendererContents.send('desktop:runtime-invalidated', event) }) await queueWindowModeSwitch(currentWindowMode) initTray( () => { revealActiveWindowSafely('tray') }, () => { quitAfterClearingSaasSession() }, ) startDesktopHealthIndicator(() => currentMainWindow()) queueDesktopDeepLink(extractDesktopDeepLink(process.argv)) flushPendingDesktopDeepLinks() }) .catch((error) => { console.error('[desktop-main] startup failed', error) }) app.on('activate', () => { revealActiveWindowSafely('activate') }) app.on('open-url', (event, url) => { event.preventDefault() revealActiveWindowSafely('deep-link') queueDesktopDeepLink(url) }) app.on('window-all-closed', () => { if (getDesktopAppSettings().keepRunningInBackground) { return } if (process.platform !== 'darwin') { app.quit() } }) app.on('before-quit', (event) => { if (quitReleaseInFlight) { return } quitReleaseInFlight = true event.preventDefault() void Promise.race([ releaseRuntimeSession(), new Promise((resolve) => setTimeout(resolve, 1500)), ]).finally(() => { app.quit() }) }) }