Files
geo/apps/desktop-client/src/main/bootstrap.ts
T
root 04bd3e42e0
Desktop Client Build / Resolve Build Metadata (push) Successful in 37s
Backend CI / Backend (push) Failing after 9m26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m29s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 35s
fix: harden desktop monitoring recovery
2026-06-23 23:12:25 +08:00

1340 lines
39 KiB
TypeScript

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<void> | null = null
let mainWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let loginWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let settingsWindowCreatePromise: Promise<ElectronBrowserWindow> | null = null
let windowModeSwitchQueue: Promise<void> = Promise.resolve()
let clientTokenRotatePromise: Promise<DesktopClientRotateResponse> | null = null
let canHandleDesktopDeepLinks = false
const pendingDesktopDeepLinks: string[] = []
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>()
const appWindowModes = new WeakMap<ElectronBrowserWindow, RendererWindowMode>()
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<WindowMode, WindowSizePreset> = {
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<void> {
if (window.isDestroyed() || !window.webContents.isLoading()) {
return
}
await new Promise<void>((resolve) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | 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<void> {
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<void> {
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<ElectronBrowserWindow> {
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<ElectronBrowserWindow> {
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<ElectronBrowserWindow> {
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<ElectronBrowserWindow> {
return mode === 'main' ? ensureMainWindow() : ensureLoginWindow()
}
async function revealWindow(window: ElectronBrowserWindow): Promise<void> {
await waitForWindowReady(window)
if (window.isMinimized()) {
window.restore()
}
if (!window.isVisible()) {
window.show()
}
window.focus()
app.focus({ steal: true })
}
async function revealActiveWindow(): Promise<void> {
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<void> {
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<void> {
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<void> {
const nextSwitch = windowModeSwitchQueue
.catch(() => undefined)
.then(() => switchWindowMode(mode, request, sourceWindow))
windowModeSwitchQueue = nextSwitch.catch(() => undefined)
return nextSwitch
}
async function openSettingsWindow(): Promise<void> {
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<void> {
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<void> {
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<ElectronBrowserWindow> {
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<ElectronBrowserWindow> {
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<Args extends unknown[], Result>(
channel: string,
handler: (...args: Args) => Promise<Result> | 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<DesktopClientReleaseCheckResponse> {
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<void>((resolve) => setTimeout(resolve, 1500)),
]).finally(() => {
app.quit()
})
})
}