94c6b5d5a5
Desktop Client Build / Resolve Build Metadata (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m48s
Desktop Client Build / Build Desktop Client (push) Successful in 26m5s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 42s
Business calls (heartbeat, account sync, lease/pull/resume tasks, preempt monitoring lease) used to short-circuit any 401 into handleAuthExpired, which tore down the runtime and switched the window to login. That stole the screen on transient or per-request auth glitches and conflicted with the renderer-side proactive token renewal. Now business 401s fall through to the existing warn/danger activity log paths and runtime keeps running. The login redirect is owned solely by the renderer's renewAuthenticatedSession → on refresh / rotate failure → forceLogoutAfterRenewFailure path. Server-side revocation via error code 40991 is unaffected. Removed the now-orphan plumbing: handleAuthExpired, isApiClientError helper, emitRuntimeAuthExpired / onRuntimeAuthExpired events, and forceLoginWindowForAuthExpired listener registration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1055 lines
30 KiB
TypeScript
1055 lines
30 KiB
TypeScript
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
import type {
|
|
DesktopAccountInfo,
|
|
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 { resolveDesktopClientID, resolveDesktopDeviceInfo } from './device-id'
|
|
import {
|
|
installObservedGlobalFetch,
|
|
registerObservedRequestRendererTarget,
|
|
} from './network-observer'
|
|
import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from './playwright-cdp'
|
|
import { initProcessMetricsSampler } from './process-metrics'
|
|
import { registerRendererDevtoolsProxyTarget } from './renderer-devtools-proxy'
|
|
import {
|
|
noteRuntimeAccountBound,
|
|
refreshRuntimeAccounts,
|
|
getRuntimeControllerSnapshot,
|
|
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 {
|
|
initTransport,
|
|
listDesktopPublishTasks,
|
|
retryDesktopPublishTask,
|
|
rotateDesktopClient,
|
|
} from './transport/api-client'
|
|
import { initTray, showTrayBalloon } from './tray'
|
|
import { STANDARD_USER_AGENT } from './user-agent'
|
|
import { startHotViewReaper } from './view-pool'
|
|
|
|
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 in packaged builds.
|
|
app.commandLine.appendSwitch('remote-debugging-port', String(getPlaywrightCDPPort()))
|
|
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()
|
|
if (isDevelopmentRuntime) {
|
|
installObservedGlobalFetch()
|
|
}
|
|
|
|
console.info('[desktop-main] boot', {
|
|
electron: process.versions.electron,
|
|
chrome: process.versions.chrome,
|
|
node: process.versions.node,
|
|
ua: STANDARD_USER_AGENT,
|
|
})
|
|
|
|
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 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>()
|
|
|
|
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 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 flattenError(error: unknown): Error {
|
|
if (error instanceof Error) {
|
|
const copy = new Error(error.message || 'ipc_handler_failed')
|
|
copy.name = error.name
|
|
return copy
|
|
}
|
|
return new Error(typeof error === 'string' ? error : 'ipc_handler_failed')
|
|
}
|
|
|
|
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(error)
|
|
}
|
|
})
|
|
}
|
|
|
|
function isSafeExternalUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url)
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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: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: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: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())
|
|
safeHandle(
|
|
'desktop:set-app-setting',
|
|
async (_event, key: DesktopAppSettingKey, value: boolean) => {
|
|
if (key !== 'openAtLogin' && key !== 'keepRunningInBackground') {
|
|
throw new Error('unsupported_app_setting')
|
|
}
|
|
return setDesktopAppSetting(key, value)
|
|
},
|
|
)
|
|
ipcMain.handle(
|
|
'desktop:runtime-session-sync',
|
|
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
|
|
syncRuntimeSession(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
|
|
})
|
|
ipcMain.handle('desktop:set-window-mode', async (event, 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 {
|
|
app
|
|
.whenReady()
|
|
.then(async () => {
|
|
suppressNativeWindowMenu()
|
|
registerDesktopProtocolClient()
|
|
currentWindowMode = 'login'
|
|
persistWindowMode('login')
|
|
initDesktopAppSettings()
|
|
registerBridgeHandlers()
|
|
await initSessionRegistry()
|
|
initAccountHealth()
|
|
initTransport()
|
|
initScheduler()
|
|
initProcessMetricsSampler()
|
|
startHotViewReaper()
|
|
startHiddenPlaywrightReaper()
|
|
onRuntimeInvalidated((event) => {
|
|
syncDesktopHealthIndicator(currentMainWindow())
|
|
if (!mainRendererContents || mainRendererContents.isDestroyed()) {
|
|
return
|
|
}
|
|
mainRendererContents.send('desktop:runtime-invalidated', event)
|
|
})
|
|
await queueWindowModeSwitch(currentWindowMode)
|
|
initTray(() => {
|
|
revealActiveWindowSafely('tray')
|
|
})
|
|
startDesktopHealthIndicator(() => currentMainWindow())
|
|
queueDesktopDeepLink(extractDesktopDeepLink(process.argv))
|
|
flushPendingDesktopDeepLinks()
|
|
})
|
|
.catch((error) => {
|
|
console.error('[desktop-main] app.whenReady 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()
|
|
|
|
currentWindowMode = 'login'
|
|
persistWindowMode('login')
|
|
|
|
void Promise.race([
|
|
(async () => {
|
|
await clearRendererDesktopSessions()
|
|
await releaseRuntimeSession()
|
|
})(),
|
|
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
|
]).finally(() => {
|
|
app.quit()
|
|
})
|
|
})
|
|
}
|