返回源码地图

apps/desktop/src/main.ts

main snapshot · da00f7f5358f · 正文引用章节 15;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
2import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
3/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
4
5import { readFile, writeFile } from 'node:fs/promises'
6import { join } from 'node:path'
7import { fileURLToPath } from 'node:url'
8import {
9 app,
10 BrowserWindow,
11 clipboard,
12 dialog,
13 ipcMain,
14 Menu,
15 powerMonitor,
16 nativeImage,
17 nativeTheme,
18 net,
19 protocol,
20 session,
21 shell,
22 type IpcMainInvokeEvent,
23 type MenuItemConstructorOptions,
24} from 'electron'
25import { resolveDesktopPaths } from './paths.ts'
26import { DesktopProjectManager } from './project-manager.ts'
27import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
28import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
29import { installDesktopDirectoryPicker } from './directory-picker.ts'
30import { installMicrophonePermissions } from './microphone-permissions.ts'
31import { DesktopBackendController } from './backend-controller.ts'
32import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
33import { readDeviceInfo } from './device-info.ts'
34import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
35import { claimDesktopSingleInstance } from './single-instance.ts'
36import { DesktopUpdateCoordinator } from './update-coordinator.ts'
37import { DesktopCommandManager } from './command-management.ts'
38import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
39import { DesktopFatalRecovery } from './fatal-recovery.ts'
40import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts'
41import { openWelcomeWindow } from './welcome-window.ts'
42import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts'
43import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts'
44import { DesktopUpdateJournal } from './update-journal.ts'
45import { DesktopUpdatePreparationError } from './update-error.ts'
46import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
47import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
48import { desktopErrorState } from './startup-error.ts'
49import { readDesktopLoginShellEnvironment, resolveDesktopLoginShellConfig } from './login-shell-environment.ts'
50import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
51import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
52import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
53import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
54import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
55import { readDesktopRuntime } from './runtime-tree.ts'
56import { DesktopBrowserGuests } from './browser-guests.ts'
57import { installDesktopShortcuts } from './keyboard.ts'
58import { DesktopUpdateOverlays } from './update-overlay.ts'
59import { DesktopQuitConfirmation } from './quit-confirmation.ts'
60import { DesktopTray } from './tray.ts'
61import { DesktopBackgroundNotice } from './background-notice.ts'
62
63let focusPrimaryWindow = (): void => {}
64let stopForRecovery = async (): Promise<void> => {}
65let shuttingDown = false
66/**
67 * Set by quit entries that must not ask: crash recovery exit and restart, and the
68 * development restart command. The installer handoff has its own before-quit branch.
69 */
70let skipQuitConfirmation = false
71let windowsLanguage: string | undefined
72/**
73 * Whether the backend has reached ready: false until the first ready, back to
74 * false when a restart returns it to starting, frozen during shutdown so a
75 * failure while tearing down a ready backend still reads as `running`.
76 */
77let backendReady = false
78/** Error-level console output of the primary window, attached to crash reports. */
79const rendererConsole = new RendererConsoleTail()
80
81// Platform-conventional logs directory (macOS ~/Library/Logs/<name>, otherwise under userData);
82// set before ready so the first fatal report already resolves under it.
83app.setAppLogsPath()
84
85function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
86 return resolveDesktopLocale(windowsLanguage ?? app.getLocale())
87}
88/** Quit without the task confirmation; the caller has already decided the application must stop. */
89function quitWithoutConfirmation(): void {
90 skipQuitConfirmation = true
91 app.quit()
92}
93const recovery = new DesktopFatalRecovery({
94 messages: () => currentDesktopLocale().messages,
95 show: options => dialog.showMessageBox(options),
96 stop: () => { shuttingDown = true; return stopForRecovery() },
97 disablePlugins: async () => {
98 const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
99 const backupPath = await manager.disableAllPlugins()
100 console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
101 },
102 exit: () => { quitWithoutConfirmation() },
103 restart: () => { app.relaunch(); quitWithoutConfirmation() },
104 writeReport: (error, source) => persistCrashReport(error, source),
105})
106
107function persistCrashReport(error: unknown, source: CrashReportSource): Promise<string | undefined> {
108 return writeCrashReport(app.getPath('logs'), {
109 source,
110 phase: backendReady ? 'running' : 'startup',
111 error,
112 ...(error instanceof DesktopHostFatalError && error.diagnostic !== undefined ? { hostDiagnostic: error.diagnostic } : {}),
113 rendererConsole: rendererConsole.snapshot(),
114 app: {
115 name: app.name, version: app.getVersion(), platform: process.platform, arch: process.arch,
116 electron: process.versions.electron, node: process.versions.node, locale: currentDesktopLocale().id,
117 },
118 time: new Date(),
119 })
120}
121
122function reportFatal(error: unknown, source: CrashReportSource): void {
123 console.error(error)
124 if (shuttingDown) {
125 // No dialog during shutdown, but the report still records what failed on the way out.
126 void persistCrashReport(error, source)
127 return
128 }
129 void recovery.report(error, source).catch((failure: unknown) => { console.error(failure); app.exit(1) })
130}
131
132protocol.registerSchemesAsPrivileged([{
133 scheme: SCHEME,
134 privileges: {
135 standard: true,
136 secure: true,
137 supportFetchAPI: true,
138 corsEnabled: true,
139 stream: true,
140 codeCache: true,
141 },
142}])
143
144interface RuntimeResources {
145 readonly nodeBin: string
146 readonly node: string
147 readonly pnpm: string
148 readonly dsh: string
149}
150
151function runtimeResources(): RuntimeResources {
152 const development = !app.isPackaged
153 const node = process.execPath
154 const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin')
155 const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
156 ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
157 : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
158 const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
159 ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh'))
160 return { node, nodeBin, pnpm, dsh }
161}
162
163function developmentPrimaryRuntime(): string {
164 const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR
165 if (directory === undefined || directory === '') {
166 throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
167 }
168 return directory
169}
170
171function developmentHostInspectPort(enabled: boolean): number | undefined {
172 const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
173 if (!enabled || configured === undefined || configured === '') return undefined
174 const port = Number(configured)
175 if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
176 throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535')
177 }
178 return port
179}
180
181/**
182 * Opaque chrome fallback matching the built-in sidebar palette (the resolved
183 * `--dsw-static-neutral-bluish-900` / `-50` tokens). An approximation for
184 * custom themes: Windows swaps in the renderer's measured palette over the
185 * windowsAppearance IPC, and macOS shows it only while minimized or hidden.
186 * @returns the sidebar fill hex for the active system color scheme.
187 */
188function chromeFallbackFill(): string {
189 return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb'
190}
191
192/**
193 * Add the effective Desktop palette to a Platform authorization URL so the
194 * login page opens in the application's theme. `system` resolves through
195 * `nativeTheme.shouldUseDarkColors`, which follows the theme source the
196 * application preload publishes.
197 * @param authorizeUrl - validated Platform authorization URL.
198 * @returns the authorization URL carrying `theme=light` or `theme=dark`.
199 */
200function platformLoginUrl(authorizeUrl: string): string {
201 const url = new URL(authorizeUrl)
202 url.searchParams.set('theme', nativeTheme.shouldUseDarkColors ? 'dark' : 'light')
203 return url.href
204}
205
206function createWindow(preload: string, show = false, primary = false): BrowserWindow {
207 const window = new BrowserWindow({
208 width: 1280,
209 height: 820,
210 minWidth: 520,
211 minHeight: 600,
212 show,
213 ...(process.platform === 'win32' && primary ? {
214 titleBarStyle: 'hidden' as const,
215 titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: chromeFallbackFill(),
216 symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' },
217 } : {}),
218 // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy
219 // needs a transparent window background to show through the page.
220 ...(process.platform === 'darwin' ? {
221 titleBarStyle: 'hiddenInset' as const,
222 trafficLightPosition: { x: 16, y: 18 },
223 vibrancy: 'sidebar' as const,
224 // 'active' keeps the vibrancy material stable when the window blurs;
225 // 'followWindow' washes the sidebar out behind an unfocused window.
226 visualEffectState: 'active' as const,
227 backgroundColor: '#00000000',
228 } : {}),
229 webPreferences: {
230 preload,
231 nodeIntegration: false,
232 contextIsolation: true,
233 sandbox: true,
234 webSecurity: true,
235 webviewTag: primary,
236 devTools: true,
237 },
238 })
239 window.webContents.setWindowOpenHandler(({ url }) => {
240 if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url)
241 return { action: 'deny' }
242 })
243 if (process.platform === 'darwin' || process.platform === 'win32') {
244 // Fullscreen hides native window controls; overlays drop their caption clearance.
245 const sendFullscreen = (): void => {
246 if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC.windowFullscreen, window.isFullScreen())
247 }
248 window.on('enter-full-screen', sendFullscreen)
249 window.on('leave-full-screen', sendFullscreen)
250 // Reloads and navigations re-register the preload listener; resend the
251 // current state so a fullscreen reload does not fall back to windowed CSS.
252 window.webContents.on('did-finish-load', sendFullscreen)
253 }
254 if (process.platform === 'darwin') {
255 // Deminiaturize reattaches the NSVisualEffectView material late
256 // (electron/electron#25368), so a transparent window shows the desktop
257 // through the sidebar until then. Paint an opaque base while minimized or
258 // hidden so the deminiaturize animation and the reattachment gap show a
259 // solid fill; restoring flips back, and the null -> 'sidebar' transition
260 // forces the material to reattach.
261 const applyBackdrop = (): void => {
262 if (window.isDestroyed()) return
263 if (window.isMinimized() || !window.isVisible()) {
264 window.setVibrancy(null)
265 window.setBackgroundColor(chromeFallbackFill())
266 } else {
267 window.setVibrancy('sidebar')
268 window.setBackgroundColor('#00000000')
269 }
270 }
271 window.on('minimize', applyBackdrop)
272 window.on('hide', applyBackdrop)
273 window.on('restore', applyBackdrop)
274 window.on('show', applyBackdrop)
275 }
276 window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => {
277 const items: MenuItemConstructorOptions[] = []
278 if (isEditable) {
279 items.push(
280 { role: 'undo', enabled: editFlags.canUndo },
281 { role: 'redo', enabled: editFlags.canRedo },
282 { type: 'separator' },
283 { role: 'cut', enabled: editFlags.canCut },
284 { role: 'copy', enabled: editFlags.canCopy },
285 { role: 'paste', enabled: editFlags.canPaste },
286 { type: 'separator' },
287 { role: 'selectAll', enabled: editFlags.canSelectAll },
288 )
289 } else if (selectionText.length > 0) {
290 items.push({ role: 'copy', enabled: editFlags.canCopy })
291 }
292 // Empty accelerators suppress Electron's default shortcut labels for native roles.
293 if (items.length > 0) {
294 const messages = currentDesktopLocale().messages
295 Menu.buildFromTemplate(items.map(item => ({
296 ...item,
297 ...(process.platform === 'win32' && item.role !== undefined && item.role in messages
298 ? { label: messages[item.role as keyof typeof messages] } : {}),
299 accelerator: '',
300 }))).popup({ window })
301 }
302 })
303 window.webContents.on('will-navigate', (event, url) => {
304 const destination = new URL(url)
305 const current = new URL(window.webContents.getURL())
306 if (destination.protocol !== `${SCHEME}:`
307 && !(destination.protocol === 'http:' && destination.origin === current.origin)) {
308 event.preventDefault()
309 if (['http:', 'https:'].includes(destination.protocol)) void shell.openExternal(url)
310 }
311 })
312 return window
313}
314
315async function main(): Promise<void> {
316 void pruneCrashReports(app.getPath('logs'))
317 const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
318 const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
319 const resources = runtimeResources()
320 const paths = resolveDesktopPaths()
321 const development = !app.isPackaged
322 const primaryRuntime = development
323 ? developmentPrimaryRuntime()
324 : join(process.resourcesPath, 'runtime', 'primary-runtime')
325 const activeProject = paths.profile
326 const manager = new DesktopProjectManager(paths, resources)
327 // Dock and Finder launches inherit only launchd's environment; every Host shares one login-shell read.
328 const loginShellRead = new AbortController()
329 // The probe runs in its own process group, which outlives Desktop unless the read is aborted.
330 app.on('will-quit', () => { loginShellRead.abort() })
331 const loginShell = readDesktopLoginShellEnvironment(process.env, resolveDesktopLoginShellConfig(process.env), {
332 signal: loginShellRead.signal,
333 }).then((result) => {
334 for (const failure of result.failures) console.warn(`desktop login shell: ${failure.shell} failed (${failure.reason})`)
335 return result.environment
336 })
337 let hostEnvironment: NodeJS.ProcessEnv = process.env
338 const prepareHostEnvironment = async (): Promise<void> => { hostEnvironment = await loginShell }
339 let quitting = false
340 let startup: Promise<void> | undefined
341 let workspaceRecovery: Promise<void> | undefined
342 let mainWindow: BrowserWindow | undefined
343 let welcomeWindow: BrowserWindow | undefined
344 let enteredWorkspace = false
345 // NSIS passes --updated when it launches the application after installation.
346 let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
347 let shellInstallerOwnsQuit = false
348 let requireCleanStop = false
349 let updateStoppedHost = false
350 let updateStopFailure: DesktopHostUncleanExitError | undefined
351 let updateState: DesktopUpdateState = { phase: 'idle' }
352 const systemLanguages = app.getPreferredSystemLanguages()
353 let locale = resolveDesktopStartupLocale(null, systemLanguages)
354 windowsLanguage = locale.id
355 let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
356 let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
357 let policyAuth: DesktopPolicyTestAuth | undefined
358 let tray: DesktopTray | undefined
359 /**
360 * The operating system is ending the session: the quit skips its confirmation. Windows sets it
361 * on the definitive session-end message. macOS sets it on the power-off notification, which
362 * another application can still cancel, so the next focus or show of the main window clears it.
363 */
364 let sessionEnding = false
365 const isQuitting = (): boolean => quitting
366 const currentMainWindow = (): BrowserWindow | undefined => mainWindow
367 const ordinaryDialogs = new Set<AbortController>()
368 const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow
369 const updateOverlays = new DesktopUpdateOverlays()
370 const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays)
371 const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
372 const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
373 const controller = new AbortController()
374 ordinaryDialogs.add(controller)
375 try {
376 const parent = currentDialogWindow()
377 if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
378 return await updateDialog.show(parent, { ...options, signal: controller.signal })
379 }
380 finally { ordinaryDialogs.delete(controller) }
381 }
382 // Copy comes from the same locale as the update prompts so the dialog
383 // chrome and its content never mix languages.
384 const showAbout = async (): Promise<void> => {
385 await ordinaryMessageBox({ type: 'info', title: locale.messages.aboutMenu, message: locale.messages.aboutProduct,
386 detail: formatDesktopMessage(locale.messages.aboutVersion, { version: app.getVersion() }),
387 buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
388 }
389 const commandManager = new DesktopCommandManager({
390 resources: process.resourcesPath,
391 isPackaged: app.isPackaged,
392 isInstalledLocation: () => process.platform !== 'darwin' || app.isInApplicationsFolder(),
393 isInstalling: () => updateState.phase === 'installing',
394 isQuitting,
395 messages: () => currentDesktopLocale().messages,
396 show: ordinaryMessageBox,
397 })
398 const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
399 const applicationUrl = `${SCHEME}://app/`
400 let hostUrl: string | undefined
401 let hostCookie: string | undefined
402 const browserGuests = new DesktopBrowserGuests(() => hostUrl)
403 let injections: readonly unknown[] = []
404 let welcomeBackend: DesktopWelcomeBackend | undefined
405 let reportedLaunch = false
406 let analyticsEnabled = false
407 const track = async <K extends keyof ProductEventMap>(eventName: K, attributes: ProductEventMap[K]): Promise<void> => {
408 const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
409 try {
410 if (analyticsEnabled) await welcomeBackend?.report(event)
411 } catch (_error) { /* Analytics cannot interrupt native actions. */ }
412 }
413 let stopAccount: (() => void) | undefined
414 let openedAttempt: string | undefined
415 let returnedAttempt: string | undefined
416 let pendingWelcomeNotice: WelcomeNotice | undefined
417 let previousAccountStatus: string | undefined
418 const assertProductSender = (event: IpcMainInvokeEvent): void => {
419 assertDesktopSender(event, ['app'])
420 if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
421 || event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
422 throw new Error('dsh desktop: rejected IPC from an unowned renderer')
423 }
424 }
425 let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
426 const navigateMain = (url: string): Promise<void> => {
427 const window = mainWindow
428 if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve()
429 if (navigation?.window === window && navigation.url === url) return navigation.promise
430 const next = { window, url, promise: Promise.resolve() }
431 next.promise = window.loadURL(url).catch((error: unknown) => {
432 if (quitting || shuttingDown || window.isDestroyed() || navigation !== next
433 || (error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED')) return
434 navigation = undefined
435 throw error
436 })
437 navigation = next
438 return next.promise
439 }
440 const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'),
441 () => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
442 const backend = new DesktopBackendController((onFailure) => {
443 const hostInspectPort = developmentHostInspectPort(development)
444 const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
445 hostInspectPort, { ...hostEnvironment, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
446 primaryRuntime,
447 resources, (next) => { platformView.setSession(next) })
448 return {
449 start: async () => {
450 const ready = await host.start()
451 hostCookie = await authenticateWebHost(ready.url)
452 hostUrl = ready.url
453 if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
454 injections = ready.injections
455 welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
456 analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
457 if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
458 stopAccount?.()
459 const accountBackend = welcomeBackend.account
460 stopAccount = accountBackend.watch((state) => {
461 if (quitting) return
462 if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
463 const attempt = state.attempt
464 if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
465 openedAttempt = attempt.id
466 void shell.openExternal(platformLoginUrl(attempt.authorizeUrl)).catch(() => undefined)
467 }
468 if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
469 returnedAttempt = attempt.id
470 focusPrimaryWindow()
471 }
472 if (state.status === 'credential-stored' && attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace({ activate: false }).catch(() => undefined)
473 if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') {
474 void readWelcomeState().then(async (value) => {
475 if (needsWelcome(value) && !quitting) {
476 enteredWorkspace = false
477 await showWelcome()
478 if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
479 }
480 return undefined
481 }).catch(() => undefined)
482 }
483 previousAccountStatus = state.status
484 }, () => {
485 // The stream reconnects; a transport failure does not change account state.
486 }, () => {
487 void readWelcomeState().then(async (value) => {
488 if (!needsWelcome(value) || quitting) return
489 pendingWelcomeNotice = 'session-expired'
490 enteredWorkspace = false
491 await showWelcome()
492 const state = await accountBackend.state()
493 if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
494 }).catch(() => undefined)
495 }, (enabled) => { analyticsEnabled = enabled })
496 },
497 stop: async () => {
498 analyticsEnabled = false
499 stopAccount?.()
500 try { await host.stop(requireCleanStop) }
501 catch (error) {
502 if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
503 // Backend cleanup succeeded; installation still rejects the unsuccessful task teardown.
504 updateStopFailure = error
505 }
506 },
507 updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
508 inspectQuit: () => host.inspectQuit(),
509 }
510 }, (state) => {
511 if (state.phase === 'error') reportFatal(state.failure, 'host')
512 else if (!shuttingDown) backendReady = state.phase === 'ready'
513 })
514
515 const updateErrors = new WeakMap<DesktopUpdateState, Promise<void>>()
516 const showUpdateFailure = (state: DesktopUpdateState): Promise<void> => {
517 if (state.phase !== 'error') return Promise.resolve()
518 if (isMandatory()) { mandatoryUI?.sync(); return Promise.resolve() }
519 let shown = updateErrors.get(state)
520 if (shown === undefined) {
521 shown = ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle,
522 message: desktopUpdateErrorSummary(state, locale.messages),
523 technicalDetails: state.technicalDetails ?? state.message ?? '' }).then(() => {})
524 updateErrors.set(state, shown)
525 }
526 return shown
527 }
528 const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
529 updateJournal?.state(state)
530 updateState = state
531 mandatoryUI?.sync()
532 for (const window of BrowserWindow.getAllWindows()) {
533 window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
534 }
535 if (state.phase === 'error' && state.failedOperation !== 'check') {
536 const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
537 shellInstallerOwnsQuit = false
538 updateStoppedHost = false
539 if (restoreHost) {
540 // Only confirmed process exit permits replacement before another installation confirmation.
541 const hostReady = backend.start(prepareHostEnvironment)
542 startup = hostReady
543 const recovery = hostReady.then(async () => {
544 if (quitting) return
545 // A replacement Host can have a new port, cookie, or boot injections even at the same URL.
546 navigation = undefined
547 await navigateMain(applicationUrl)
548 if (backend.host !== undefined) updateJournal?.action('workspace-ready')
549 })
550 workspaceRecovery = recovery
551 void recovery.catch((error: unknown) => { reportFatal(error, 'main') }).finally(() => {
552 if (startup === hostReady) startup = undefined
553 if (workspaceRecovery === recovery) workspaceRecovery = undefined
554 })
555 }
556 void showUpdateFailure(state).catch((error: unknown) => { console.error(error) })
557 }
558 return state
559 }
560
561 const readWelcomeState = async () => {
562 if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
563 return welcomeBackend.read()
564 }
565 stopForRecovery = () => backend.close()
566
567 const reconcileBackend = (): Promise<void> => {
568 startup ??= (async () => {
569 await navigateMain(applicationUrl)
570 await backend.start(async () => {
571 await Promise.all([manager.applyRelease(), prepareHostEnvironment()])
572 })
573 if (backend.host !== undefined) await openInitialWindow()
574 if (backend.host !== undefined) updateJournal?.action('workspace-ready')
575 // The existing Web document resumes through the boot IPC response.
576 })().catch((error: unknown) => {
577 updateJournal?.action('workspace-failed')
578 reportFatal(error, 'main')
579 throw error
580 }).finally(() => { startup = undefined })
581 return startup
582 }
583
584 const updates = new DesktopUpdateCoordinator(
585 publishUpdate,
586 async () => {
587 await commandManager.idle()
588 await workspaceRecovery
589 await startup?.catch(() => undefined)
590 const host = backend.host
591 if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
592 const active = await host.updateTasks('inspect')
593 const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform)
594 const confirmation: Electron.MessageBoxOptions = {
595 type: active ? 'warning' : 'info', title: locale.messages.updateTitle,
596 message: active ? locale.messages.updateActiveTasks : ready.message,
597 detail: active ? locale.messages.updateActiveTasksDetail : ready.detail,
598 buttons: active ? [locale.messages.updateStopTasks, locale.messages.updateLater] : [locale.messages.installAndRestart],
599 defaultId: 1, cancelId: 1,
600 }
601 if (isMandatory()) {
602 if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
603 } else {
604 const parent = currentDialogWindow()
605 if (parent === undefined) return false
606 const result = await updateDialog.show(parent, confirmation)
607 if (result.response !== 0 || isMandatory()) return false
608 }
609 // The update lock rejects new HTTP requests, including analytics intake.
610 await track('desktop_upgrade_install_restart_click', {})
611 if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
612 try {
613 const stillActive = await host.updateTasks('lock')
614 if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', locale.messages.updateTasksChanged)
615 mandatoryUI?.preparingRestart(stillActive)
616 // The embedded Platform document holds credentials issued by the Host that is about to stop.
617 await platformView.closeAndWait()
618 requireCleanStop = true
619 updateStopFailure = undefined
620 await backend.stop()
621 updateStoppedHost = true
622 // The backend's async cleanup callback can assign this after the reset above.
623 const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined
624 if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', locale.messages.updateStopFailed, stopFailure.message)
625 updateJournal?.action('install-confirmed')
626 shellInstallerOwnsQuit = true
627 } catch (error) {
628 if (!updateStoppedHost) await host.updateTasks('unlock').catch((unlockError: unknown) => { console.error(unlockError) })
629 throw error
630 } finally {
631 requireCleanStop = false
632 }
633 return true
634 },
635 undefined, undefined, undefined,
636 (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
637
638 )
639
640 const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
641
642 const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
643 void track('desktop_upgrade_click', {})
644 updateJournal?.action('download-requested')
645 const state = await updates.download(version)
646 if (state.phase !== 'ready' || quitting) return state
647 // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it.
648 // A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory
649 // flow keeps its own taskbar and Dock attention instead.
650 if (!isMandatory()) await windowShown()
651 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited.
652 if (quitting) return state
653 return updates.install(version)
654 }
655 const windowShown = (): Promise<void> => new Promise((resolve) => {
656 const window = currentDialogWindow()
657 if (window === undefined || window.isDestroyed() || window.isVisible()) { resolve(); return }
658 window.once('show', () => { resolve() })
659 window.once('closed', () => { resolve() })
660 })
661
662 protocol.handle(SCHEME, (request) => {
663 const url = new URL(request.url)
664 // Shell-owned documents live in the application bundle and never pass through the Host.
665 if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
666 if (url.hostname === 'app') {
667 if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
668 || ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
669 return serveWebDocument(request, join(resources.dsh, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'dist'))
670 }
671 if (backend.host === undefined || hostUrl === undefined || hostCookie === undefined) {
672 return Promise.resolve(new Response(null, { status: 503 }))
673 }
674 return forwardWebRequest(request, hostUrl, hostCookie)
675 }
676 return Promise.resolve(new Response(null, { status: 404 }))
677 })
678
679 installDesktopDirectoryPicker(() => mainWindow)
680 installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
681 const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'),
682 process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window))
683 app.on('will-quit', () => { shortcuts.dispose() })
684
685 ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
686 assertDesktopSender(event, ['app'])
687 await startup
688 if (backend.host === undefined || hostUrl === undefined) throw new Error('Desktop Host is unavailable')
689 return { injections, streamBaseUrl: new URL(hostUrl).origin }
690 })
691
692 ipcMain.handle(DESKTOP_IPC.bootFailed, (event, message: unknown) => {
693 assertDesktopSender(event, ['app'])
694 if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) {
695 throw new Error('dsh desktop: rejected startup failure from a non-primary frame')
696 }
697 if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text')
698 reportFatal(new Error(message), 'web-boot')
699 })
700
701 ipcMain.handle(DESKTOP_IPC.browserAcquire, (event, workspace: unknown) => {
702 assertProductSender(event)
703 return browserGuests.acquire(event.sender, workspace)
704 })
705 ipcMain.handle(DESKTOP_IPC.browserRelease, (event, lease: unknown) => {
706 assertProductSender(event)
707 return browserGuests.release(event.sender, lease)
708 })
709
710 session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
711 if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
712 callback({})
713 return
714 }
715 const target = new URL(hostUrl)
716 const requested = new URL(details.url)
717 if (requested.host !== target.host) { callback({}); return }
718 const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
719 if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return }
720 callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
721 })
722
723 const assertMainApplication = (event: IpcMainInvokeEvent): BrowserWindow => {
724 const owner = mainWindow
725 if (owner === undefined || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame
726 || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command')
727 return owner
728 }
729 ipcMain.on(PLATFORM_IPC.bootstrap, (event) => {
730 try { event.returnValue = platformView.bootstrap(event) }
731 catch { event.returnValue = null }
732 })
733 ipcMain.handle(PLATFORM_IPC.open, (event, page: unknown, bounds: unknown) => {
734 const owner = assertMainApplication(event)
735 if (page !== 'usage' && page !== 'top-up') throw new Error('Invalid Platform page')
736 return platformView.open(owner, page, platformBounds(bounds))
737 })
738 ipcMain.handle(PLATFORM_IPC.bounds, (event, bounds: unknown) => {
739 assertMainApplication(event)
740 platformView.setBounds(platformBounds(bounds))
741 })
742 ipcMain.handle(PLATFORM_IPC.close, (event) => { assertMainApplication(event); platformView.close() })
743 // Only the main window may synchronize its palette with the native material.
744 ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
745 if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
746 if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
747 })
748 ipcMain.handle(DESKTOP_IPC.localeBootstrap, async (event) => {
749 if (event.sender !== mainWindow?.webContents || event.senderFrame !== mainWindow.webContents.mainFrame
750 || new URL(event.senderFrame.url).origin !== new URL(applicationUrl).origin) {
751 throw new Error('desktop welcome: rejected locale request from an unowned frame')
752 }
753 if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
754 return { languages: systemLanguages, preference: await welcomeBackend.readLocalePreference() }
755 })
756 ipcMain.on(DESKTOP_IPC.localeChanged, (event, next: unknown) => {
757 const window = mainWindow
758 if (window === undefined || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame
759 || typeof next !== 'string') return
760 const current = resolveDesktopStartupLocale(next, systemLanguages)
761 if (current.id === locale.id) return
762 locale = current
763 platformView.notifyLocaleChanged()
764 windowsLanguage = locale.id
765 refreshApplicationMenu()
766 })
767 ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
768 assertProductSender(event)
769 return presentDesktopUpdate(updates.state)
770 })
771 ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => {
772 assertProductSender(event)
773 return readDeviceInfo()
774 })
775 ipcMain.handle(DESKTOP_IPC.onboardingApiKey, async (event) => {
776 assertProductSender(event)
777 return (await readWelcomeState()).hasApiKey
778 })
779 ipcMain.on(DESKTOP_IPC.onboardingActive, (event, active: unknown) => {
780 const window = mainWindow
781 if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
782 || event.senderFrame !== window.webContents.mainFrame
783 || !event.senderFrame.url.startsWith(`${SCHEME}://app/`) || typeof active !== 'boolean') return
784 window.setMinimumSize(active ? 960 : 520, 600)
785 if (active) {
786 const { width, height } = window.getBounds()
787 if (width < 960) window.setSize(960, height)
788 }
789 })
790 ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => {
791 assertProductSender(event)
792 await openUpdatePrompt()
793 })
794
795 let promptOperation: Promise<void> | undefined
796 let policyAuthenticationQueued = false
797 const openUpdatePrompt = (manual = false): Promise<void> => {
798 if (authenticationOperation !== undefined) {
799 policyAuth?.focus(); updateDialog.focus()
800 }
801 let failedOperation: 'check' | 'download' | 'install' = 'check'
802 promptOperation ??= Promise.resolve().then(async () => {
803 if (manual) updateJournal?.action('check-requested')
804 const joinedPolicyAuthentication = authenticationOperation !== undefined
805 if (joinedPolicyAuthentication) await authenticatePolicy()
806 if (isMandatory()) {
807 mandatoryUI?.focus()
808 if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
809 return
810 }
811 let controller: AbortController | undefined
812 let progress: Promise<unknown> | undefined
813 try {
814 let state = updates.state
815 if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
816 controller = new AbortController()
817 ordinaryDialogs.add(controller)
818 const parent = currentDialogWindow()
819 progress = parent === undefined ? Promise.resolve() : updateDialog.show(parent, { type: 'info', title: locale.messages.updateCheckTitle,
820 message: locale.messages.updateChecking, buttons: [locale.messages.later], cancelId: 0, signal: controller.signal })
821 if (!joinedPolicyAuthentication) {
822 void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
823 }
824 state = await updateSchedule.check(true)
825 }
826 if (isMandatory()) { mandatoryUI?.focus(); return }
827 if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
828 if (state.phase === 'idle') {
829 await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle,
830 message: locale.messages.updateCurrent,
831 detail: formatDesktopMessage(locale.messages.updateCurrentDetail, { version: app.getVersion() }) })
832 return
833 }
834 if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
835 if (state.version !== undefined) {
836 failedOperation = 'install'
837 await showUpdateFailure(await updates.install(state.version))
838 }
839 return
840 }
841 if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
842 if (manual) {
843 const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle,
844 message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }),
845 detail: locale.messages.updateDetail,
846 buttons: [locale.messages.updateDownload], cancelId: 1 })
847 if (result.response !== 0) return
848 }
849 if (!isMandatory() && state.version !== undefined) {
850 controller?.abort()
851 failedOperation = 'download'
852 await showUpdateFailure(await downloadUpdate(state.version))
853 }
854 } finally {
855 controller?.abort()
856 if (controller !== undefined) ordinaryDialogs.delete(controller)
857 await progress
858 }
859 }).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation,
860 message: desktopErrorState(error).message }))
861 .finally(() => { promptOperation = undefined; flushQueuedPolicyAuthentication() })
862 return promptOperation
863 }
864
865 let authenticationOperation: Promise<DesktopPolicyState | undefined> | undefined
866 const authenticatePolicy = () => {
867 if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() }
868 authenticationOperation ??= runPolicyAuthentication().finally(() => { authenticationOperation = undefined })
869 return authenticationOperation
870 }
871 const flushQueuedPolicyAuthentication = (): void => {
872 if (!policyAuthenticationQueued || promptOperation !== undefined || authenticationOperation !== undefined
873 || isMandatory() || quitting) return
874 policyAuthenticationQueued = false
875 void authenticatePolicy().catch((error: unknown) => { console.error(error) })
876 }
877 const queuePolicyAuthentication = (): void => {
878 if (authenticationOperation !== undefined) {
879 policyAuth?.focus(); updateDialog.focus()
880 return
881 }
882 policyAuthenticationQueued = true
883 flushQueuedPolicyAuthentication()
884 }
885 const runPolicyAuthentication = async () => {
886 if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
887 const parent = mandatoryUI?.confirmationWindow ?? currentDialogWindow()
888 if (parent === undefined) return undefined
889 const consent = await updateDialog.show(parent, { type: 'info', title: locale.messages.policyLoginTitle,
890 message: locale.messages.policyLoginRequired, buttons: [locale.messages.policyLogin, locale.messages.later], cancelId: 1 })
891 if (consent.response !== 0 || isQuitting()) return undefined
892 const outcome = await policyAuth.login()
893 if (isQuitting() || outcome === 'cancelled') return undefined
894 if (outcome === 'failed') {
895 await updateDialog.show(parent, { type: 'error', title: locale.messages.policyLoginTitle,
896 message: locale.messages.policyLoginFailed, buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
897 return undefined
898 }
899 // Drain a pre-login request before asking the server to evaluate the new cookies.
900 await mandatoryPolicy.check('login-return')
901 if (isQuitting()) return undefined
902 return mandatoryPolicy.check('login-return', true)
903 }
904
905 const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => {
906 if (authenticationOperation !== undefined) return authenticatePolicy()
907 const policy = await mandatoryPolicy?.check('manual', true)
908 if (policy?.error !== 'authentication-required') return policy
909 if (authentication === 'immediate') return authenticatePolicy()
910 queuePolicyAuthentication()
911 return policy
912 }
913
914 const automaticCheck = (): void => {
915 if (!quitting) void mandatoryPolicy?.check('foreground-or-resume').catch((error: unknown) => { console.error(error) })
916 if (!quitting) void updateSchedule.check().catch((error: unknown) => { console.error(error) })
917 }
918 powerMonitor.on('resume', automaticCheck)
919 app.on('will-quit', () => {
920 updateSchedule.dispose()
921 powerMonitor.off('resume', automaticCheck)
922 updates.dispose()
923 })
924
925 const applicationIconPath = development ? join(app.getAppPath(), 'resources', 'icon-windows.png')
926 : join(process.resourcesPath, 'icon.png')
927 app.setAboutPanelOptions({
928 applicationName: 'DeepSeek Harness',
929 applicationVersion: app.getVersion(),
930 // The release has no separate build number; omit Electron's bundle version.
931 version: '',
932 copyright: '',
933 iconPath: applicationIconPath,
934 })
935 // A custom application menu replaces Electron's default menu, so macOS needs
936 // its standard menus and application hide commands declared explicitly.
937 // Keep app.name stable: Electron derives its default userData directory from it.
938 const darwin = process.platform === 'darwin'
939 const platformMenus = (): MenuItemConstructorOptions[] => darwin
940 ? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }]
941 : [{ role: 'editMenu' }]
942 const hideCommands: MenuItemConstructorOptions[] = darwin
943 ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
944 { role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications },
945 { role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
946 : []
947 const applicationItems = (): MenuItemConstructorOptions[] => [
948 // Windows has no system About panel; Electron's fallback is a plain
949 // message box, so the shell shows its own dimmed dialog instead.
950 process.platform === 'win32'
951 ? { label: currentDesktopLocale().messages.aboutMenu,
952 click: () => { void showAbout().catch((error: unknown) => { console.error(error) }) } }
953 : { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
954 { type: 'separator' },
955 { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
956 ...process.platform === 'darwin' || process.platform === 'win32'
957 ? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [],
958 ...development ? [
959 { type: 'separator' as const },
960 { label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const },
961 { label: currentDesktopLocale().messages.restartAppHostMenu, click: () => {
962 if (quitting) return
963 app.relaunch()
964 quitWithoutConfirmation()
965 } },
966 ] : [],
967 { type: 'separator' },
968 ...hideCommands,
969 { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication }
970 : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
971 ]
972 const devToolsItems: MenuItemConstructorOptions[] = [
973 { role: 'toggleDevTools', visible: false },
974 { role: 'toggleDevTools', visible: false, accelerator: 'F12' },
975 ]
976 const refreshApplicationMenu = (): void => {
977 Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{
978 label: darwin ? app.name : currentDesktopLocale().messages.application,
979 submenu: [...applicationItems(), ...devToolsItems],
980 }, ...platformMenus()]))
981 tray?.relabel()
982 }
983 refreshApplicationMenu()
984 const trayIconPath = development ? join(app.getAppPath(), 'resources', 'tray-windows.ico') : join(process.resourcesPath, 'tray.ico')
985 if (process.platform === 'win32') {
986 // The tray is the way back to a hidden window; without it, relaunching the application still focuses it.
987 try {
988 tray = new DesktopTray({ iconPath: trayIconPath, locale: currentDesktopLocale,
989 open: () => { focusPrimaryWindow() }, quit: () => { app.quit() } })
990 } catch (error) { console.warn('desktop tray: unavailable', error) }
991 }
992 const backgroundNotice = process.platform === 'win32'
993 ? new DesktopBackgroundNotice({ markerPath: join(app.getPath('userData'), 'background-close-confirmed'),
994 locale: () => locale, show: ordinaryMessageBox, focus: () => { updateDialog.focus() } })
995 : undefined
996 const quitConfirmation = new DesktopQuitConfirmation({
997 locale: () => locale,
998 inspect: () => backend.host?.inspectQuit(),
999 // No owner window: a hidden window stays hidden and the native box is its own top-level window.
1000 show: options => dialog.showMessageBox(options),
1001 // macOS raises the open alert with the application; Electron exposes no handle to the Windows task
1002 // dialog, so a repeated request there only joins the open decision.
1003 focus: () => { if (process.platform === 'darwin') app.focus({ steal: true }) },
1004 // The task dialog draws its main icon at the system icon size; the multi-size ICO yields that
1005 // size directly, where the 1024 px PNG would be scaled down by GDI.
1006 ...(process.platform === 'win32' ? { icon: nativeImage.createFromPath(trayIconPath) } : {}),
1007 })
1008
1009 if (process.platform === 'win32') {
1010 ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
1011 assertDesktopSender(event, ['app'])
1012 if (mainWindow === undefined || event.sender !== mainWindow.webContents
1013 || event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error('desktop menu: rejected sender')
1014 if ((name !== 'application' && name !== 'edit')
1015 || typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)
1016 || x < 0 || y < 0 || x > 100_000 || y > 100_000) throw new Error('desktop menu: invalid popup request')
1017 const window = mainWindow
1018 // Editor-owned history listens to key events rather than Chromium's native undo stack.
1019 const editItem = (label: string, keyCode: string, modifiers: Array<'control'>, accelerator?: string): MenuItemConstructorOptions => ({
1020 label,
1021 ...(accelerator === undefined ? {} : { accelerator }),
1022 click: () => {
1023 shortcuts.sendEditingKey(keyCode, modifiers)
1024 },
1025 })
1026 const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
1027 editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'),
1028 editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'),
1029 { type: 'separator' },
1030 editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'),
1031 editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'),
1032 editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'),
1033 editItem(currentDesktopLocale().messages.delete, 'Delete', []),
1034 { type: 'separator' },
1035 editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'),
1036 ]
1037 const zoom = mainWindow.webContents.getZoomFactor()
1038 return new Promise<void>((resolve) => {
1039 Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve })
1040 })
1041 })
1042 ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => {
1043 if (mainWindow === undefined || event.sender !== mainWindow.webContents
1044 || event.senderFrame !== mainWindow.webContents.mainFrame) return
1045 if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return
1046 if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) {
1047 windowsLanguage = language
1048 }
1049 // Empty colors precede client stylesheet installation; only CSS color values cross IPC.
1050 const validColor = (value: unknown): value is string => typeof value === 'string'
1051 && /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value)
1052 if (validColor(color) && validColor(symbolColor)) mainWindow.setTitleBarOverlay({ color, symbolColor })
1053 })
1054 }
1055
1056 const hideMainWindow = (window: BrowserWindow): void => {
1057 if (process.platform === 'darwin' && window.isFullScreen()) {
1058 // Hiding a fullscreen window leaves an empty black space; leave fullscreen first.
1059 window.once('leave-full-screen', () => { if (!window.isDestroyed()) window.hide() })
1060 window.setFullScreen(false)
1061 } else {
1062 window.hide()
1063 }
1064 }
1065 const createMainWindow = (): BrowserWindow => {
1066 const window = createWindow(appPreload, false, true)
1067 mainWindow = window
1068 browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name))
1069 shortcuts.attach(window)
1070 window.on('focus', automaticCheck)
1071 // Closing hides: the page and the Host keep running, and the next show resumes the same document.
1072 window.on('close', (event) => {
1073 if (quitting || shellInstallerOwnsQuit || sessionEnding) return
1074 event.preventDefault()
1075 if (updateDialog.isOpen) { updateDialog.focus(); return }
1076 const hide = (): void => {
1077 if (!quitting && !shellInstallerOwnsQuit && !sessionEnding && !window.isDestroyed()) hideMainWindow(window)
1078 }
1079 if (backgroundNotice === undefined) hide()
1080 else backgroundNotice.close(hide)
1081 })
1082 if (process.platform === 'win32') {
1083 // Shutdown, restart, and log-off must not wait on a confirmation. query-session-end is only a
1084 // question that another application can veto without any follow-up message, so it does not count.
1085 window.on('session-end', () => { sessionEnding = true })
1086 } else {
1087 // The macOS power-off notification arrives before the terminate request; a cancelled shutdown
1088 // leaves the process running, and user attention on the window shows the session continues.
1089 window.on('focus', () => { sessionEnding = false })
1090 window.on('show', () => { sessionEnding = false })
1091 }
1092 window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
1093 window.webContents.on('console-message', (details) => {
1094 if (details.level !== 'error') return
1095 rendererConsole.push(`${details.sourceId}:${String(details.lineNumber)} ${details.message}`)
1096 })
1097 window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
1098 if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
1099 reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`), 'renderer')
1100 }
1101 })
1102 window.webContents.on('preload-error', (_event, _path, error) => {
1103 if (!quitting && !window.isDestroyed()) reportFatal(error, 'renderer')
1104 })
1105 window.webContents.on('render-process-gone', (_event, details) => {
1106 navigation = undefined
1107 if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') {
1108 reportFatal(new Error(`Desktop renderer exited: ${details.reason}`), 'renderer')
1109 }
1110 })
1111 return window
1112 }
1113 const enterWorkspace = async ({ activate = true }: { activate?: boolean } = {}): Promise<void> => {
1114 if (quitting) return
1115 const window = mainWindow ?? createMainWindow()
1116 await navigateMain(applicationUrl)
1117 if (isQuitting() || recovery.active || window.isDestroyed()) return
1118 if (activate) window.show()
1119 else window.showInactive()
1120 enteredWorkspace = true
1121 if (welcomeWindow !== undefined) {
1122 welcomeWindow.close()
1123 window.webContents.send(DESKTOP_IPC.enterWorkspace)
1124 }
1125 welcomeWindow = undefined
1126 if (raiseAfterUpdate) {
1127 raiseAfterUpdate = false
1128 window.moveTop()
1129 window.focus()
1130 }
1131 if (activate && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
1132 window.webContents.openDevTools({ mode: 'detach' })
1133 }
1134 }
1135 let openingWelcome: Promise<void> | undefined
1136 const showWelcome = (): Promise<void> => {
1137 if (quitting) return Promise.resolve()
1138 if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
1139 if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
1140 welcomeWindow.show()
1141 welcomeWindow.focus()
1142 return Promise.resolve()
1143 }
1144 openingWelcome ??= (async () => {
1145 welcomeWindow = await openWelcomeWindow(locale, {
1146 analytics: track,
1147 analyticsEnabled: () => Promise.resolve(analyticsEnabled),
1148 takeNotice: () => {
1149 const notice = pendingWelcomeNotice
1150 pendingWelcomeNotice = undefined
1151 return Promise.resolve(notice)
1152 },
1153 startSignIn: async () => {
1154 if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
1155 return welcomeBackend.account.start(desktopClientMetadata(locale.id))
1156 },
1157 cancelSignIn: async (id) => {
1158 if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
1159 return welcomeBackend.account.cancel(id)
1160 },
1161 copySignInLink: async (id) => {
1162 const state = await welcomeBackend?.account.state()
1163 if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
1164 throw new Error('desktop welcome: login link is unavailable')
1165 }
1166 await clipboard.writeText(platformLoginUrl(state.attempt.authorizeUrl))
1167 },
1168 saveApiKey: async (apiKey) => {
1169 if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
1170 const saved = await welcomeBackend.save(apiKey)
1171 if (!saved.ok) return saved
1172 await enterWorkspace()
1173 return { ok: true }
1174 },
1175 skip: enterWorkspace,
1176 })
1177 const window = welcomeWindow
1178 window.once('closed', () => {
1179 void welcomeBackend?.account.state().then((state) => {
1180 if (state.attempt !== null && !enteredWorkspace) return welcomeBackend?.account.cancel(state.attempt.id)
1181 return undefined
1182 }).catch(() => undefined)
1183 })
1184 window.once('closed', () => {
1185 if (welcomeWindow === window) welcomeWindow = undefined
1186 if (enteredWorkspace || recovery.active) return
1187 // Nothing runs before the workspace opens. macOS keeps the Dock convention and drops the
1188 // unused main window so the next activation rebuilds the welcome; elsewhere the close quits.
1189 if (process.platform === 'darwin') mainWindow?.destroy()
1190 else app.quit()
1191 })
1192 if (isQuitting() || recovery.active || enteredWorkspace) window.close()
1193 else mainWindow?.hide()
1194 })().finally(() => { openingWelcome = undefined })
1195 return openingWelcome
1196 }
1197 const openInitialWindow = async (): Promise<void> => {
1198 if (quitting || recovery.active) return
1199 const state = await readWelcomeState()
1200 if (isQuitting() || backend.state.phase !== 'ready') return
1201 locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages)
1202 windowsLanguage = locale.id
1203 refreshApplicationMenu()
1204 if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) {
1205 // A later login must retain its own activation policy instead of replaying startup focus.
1206 raiseAfterUpdate = false
1207 await showWelcome()
1208 } else {
1209 await enterWorkspace()
1210 }
1211 }
1212 focusPrimaryWindow = () => {
1213 if (quitting) return
1214 if (isMandatory()) { mandatoryUI?.focus(); return }
1215 const window = welcomeWindow ?? mainWindow
1216 if (window === undefined || window.isDestroyed()) {
1217 try { createMainWindow() } catch (error) { reportFatal(error, 'main'); return }
1218 void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch((error: unknown) => { reportFatal(error, 'main') })
1219 return
1220 }
1221 // Startup and sign-out select the visible window before activation may reveal the workspace.
1222 if (window === mainWindow && !enteredWorkspace) return
1223 if (window.isMinimized()) window.restore()
1224 window.show()
1225 window.focus()
1226 }
1227
1228 if (app.isPackaged || process.env.DSH_DESKTOP_DEV_APP === '1') app.setAsDefaultProtocolClient('dsh')
1229 app.on('open-url', (event, url) => {
1230 event.preventDefault()
1231 if (url === 'dsh://open' || url === 'dsh://open/') focusPrimaryWindow()
1232 })
1233
1234 app.on('activate', (_event, hasVisibleWindows) => {
1235 if (!hasVisibleWindows) focusPrimaryWindow()
1236 })
1237 app.on('window-all-closed', () => {
1238 if (process.platform !== 'darwin') app.quit()
1239 })
1240 if (process.platform !== 'win32') powerMonitor.on('shutdown', () => { sessionEnding = true })
1241 const finishQuit = (): void => {
1242 quitting = true
1243 shuttingDown = true
1244 updateJournal?.action('quit-requested')
1245 quitConfirmation.dispose()
1246 backgroundNotice?.dispose()
1247 tray?.dispose()
1248 stopAccount?.()
1249 if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide()
1250 if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
1251 updateSchedule.dispose()
1252 updateDialog.dispose()
1253 mandatoryUI?.dispose()
1254 void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
1255 // A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
1256 platformView.dispose().catch((error: unknown) => { console.error(error) })])
1257 .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
1258 }
1259 app.on('before-quit', (event) => {
1260 if (shellInstallerOwnsQuit) {
1261 shuttingDown = true
1262 quitConfirmation.dispose()
1263 backgroundNotice?.dispose()
1264 updateJournal?.action('quit-requested')
1265 tray?.dispose()
1266 updateDialog.dispose()
1267 mandatoryUI?.dispose()
1268 // Installation preparation already awaited Platform storage cleanup.
1269 void platformView.dispose().catch((error: unknown) => { console.error(error) })
1270 return
1271 }
1272 if (quitting) return
1273 event.preventDefault()
1274 if (skipQuitConfirmation || sessionEnding) { finishQuit(); return }
1275 void quitConfirmation.confirm().then((approved) => {
1276 if (quitting || shellInstallerOwnsQuit) return
1277 if (approved) { finishQuit(); return }
1278 // A quit that started from closing the welcome window destroyed it; a cancelled quit needs it back.
1279 if (!enteredWorkspace && !recovery.active) void showWelcome().catch((error: unknown) => { reportFatal(error, 'main') })
1280 }).catch((error: unknown) => { console.error(error); if (!quitting && !shellInstallerOwnsQuit) finishQuit() })
1281 })
1282
1283 mainWindow = createMainWindow()
1284 const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
1285 if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest')
1286 const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
1287 const policyInput: unknown = app.isPackaged
1288 ? ('dshMandatoryUpdatePolicy' in manifest ? manifest.dshMandatoryUpdatePolicy : undefined)
1289 : developmentPolicy === undefined ? undefined : JSON.parse(developmentPolicy) as unknown
1290 const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged)
1291 if (policyConfig !== undefined) {
1292 if (policyConfig.authentication === 'feishu-test') {
1293 policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, policyConfig.allowedAuthOrigins, locale,
1294 () => mandatoryUI?.confirmationWindow ?? currentDialogWindow(),
1295 (event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
1296 }
1297 if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform')
1298 let wasBlocking = false
1299 mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
1300 platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64',
1301 bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
1302 }, (state) => {
1303 if (state.error !== 'authentication-required') policyAuthenticationQueued = false
1304 if (state.blocking) {
1305 for (const controller of ordinaryDialogs) controller.abort()
1306 if (!wasBlocking) updateDialog.cancel()
1307 }
1308 mandatoryUI?.sync()
1309 if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) })
1310 wasBlocking = state.blocking
1311 }, policyAuth?.request, () => desktopClientMetadata(locale.id))
1312 const policy = mandatoryPolicy
1313 mandatoryUI = new DesktopMandatoryUpdateWindow({
1314 overlays: updateOverlays,
1315 preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
1316 allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
1317 policy: () => policy.state, update: () => updates.state,
1318 refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) },
1319 download: downloadUpdate, install: version => updates.install(version),
1320 })
1321 void mandatoryPolicy.check('launch').then((state) => {
1322 if (app.isPackaged && state.error === 'authentication-required' && !isQuitting()) queuePolicyAuthentication()
1323 }).catch((error: unknown) => { console.error(error) })
1324 }
1325 automaticCheck()
1326 await reconcileBackend().catch(() => undefined)
1327 // Window lifecycle callbacks run while backend startup is pending.
1328 if (isQuitting()) return
1329 const window = currentMainWindow()
1330 if (window !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
1331 window.webContents.openDevTools({ mode: 'detach' })
1332 }
1333 publishUpdate(updateState)
1334}
1335
1336const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
1337
1338if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => {
1339 const message = error instanceof Error ? error.message : String(error)
1340 console.error(error)
1341 const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE
1342 if (diagnosticFile !== undefined) {
1343 await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
1344 }
1345 reportFatal(error, 'main')
1346}).catch((error: unknown) => {
1347 console.error(error)
1348 app.exit(1)
1349})