815 lines
39 KiB
TypeScript
815 lines
39 KiB
TypeScript
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
|
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
|
|
|
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
ipcMain,
|
|
Menu,
|
|
powerMonitor,
|
|
nativeTheme,
|
|
protocol,
|
|
session,
|
|
shell,
|
|
type IpcMainInvokeEvent,
|
|
type MenuItemConstructorOptions,
|
|
} from 'electron'
|
|
import { resolveDesktopPaths } from './paths.ts'
|
|
import { DesktopProjectManager } from './project-manager.ts'
|
|
import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
|
|
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
|
import { DesktopBackendController } from './backend-controller.ts'
|
|
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
|
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
|
|
import { claimDesktopSingleInstance } from './single-instance.ts'
|
|
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
|
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
|
|
import { DesktopFatalRecovery } from './fatal-recovery.ts'
|
|
import { DesktopUpdateJournal } from './update-journal.ts'
|
|
import { DesktopUpdatePreparationError } from './update-error.ts'
|
|
import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
|
|
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
|
import { desktopErrorState } from './startup-error.ts'
|
|
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
|
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
|
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
|
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
|
import { readDesktopRuntime } from './runtime-tree.ts'
|
|
|
|
let focusPrimaryWindow = (): void => {}
|
|
let stopForRecovery = async (): Promise<void> => {}
|
|
let shuttingDown = false
|
|
let windowsLanguage: string | undefined
|
|
|
|
function currentDesktopLocale(): ReturnType<typeof resolveDesktopLocale> {
|
|
return resolveDesktopLocale(windowsLanguage ?? app.getLocale())
|
|
}
|
|
const recovery = new DesktopFatalRecovery({
|
|
messages: () => currentDesktopLocale().messages,
|
|
show: options => dialog.showMessageBox(options),
|
|
stop: () => { shuttingDown = true; return stopForRecovery() },
|
|
disablePlugins: async () => {
|
|
const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
|
|
const backupPath = await manager.disableAllPlugins()
|
|
console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
|
|
},
|
|
exit: () => { app.quit() },
|
|
restart: () => { app.relaunch(); app.quit() },
|
|
})
|
|
|
|
function reportFatal(error: unknown): void {
|
|
console.error(error)
|
|
if (shuttingDown) return
|
|
void recovery.report(error).catch((failure: unknown) => { console.error(failure); app.exit(1) })
|
|
}
|
|
|
|
protocol.registerSchemesAsPrivileged([{
|
|
scheme: SCHEME,
|
|
privileges: {
|
|
standard: true,
|
|
secure: true,
|
|
supportFetchAPI: true,
|
|
corsEnabled: true,
|
|
stream: true,
|
|
codeCache: true,
|
|
},
|
|
}])
|
|
|
|
interface RuntimeResources {
|
|
readonly nodeBin: string
|
|
readonly node: string
|
|
readonly pnpm: string
|
|
readonly dsh: string
|
|
}
|
|
|
|
function runtimeResources(): RuntimeResources {
|
|
const development = !app.isPackaged
|
|
const node = process.execPath
|
|
const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin')
|
|
const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
|
|
?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
|
|
: join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
|
|
const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
|
|
?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(app.getAppPath(), 'dsh'))
|
|
return { node, nodeBin, pnpm, dsh }
|
|
}
|
|
|
|
function developmentHostInspectPort(enabled: boolean): number | undefined {
|
|
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
|
|
if (!enabled || configured === undefined || configured === '') return undefined
|
|
const port = Number(configured)
|
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535')
|
|
}
|
|
return port
|
|
}
|
|
|
|
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
|
|
const window = new BrowserWindow({
|
|
width: 1280,
|
|
height: 820,
|
|
minWidth: 520,
|
|
minHeight: 600,
|
|
show,
|
|
...(process.platform === 'win32' && primary ? {
|
|
titleBarStyle: 'hidden' as const,
|
|
titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb',
|
|
symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' },
|
|
} : {}),
|
|
// hiddenInset places traffic lights inside the sidebar; sidebar vibrancy
|
|
// needs a transparent window background to show through the page.
|
|
...(process.platform === 'darwin' ? {
|
|
titleBarStyle: 'hiddenInset' as const,
|
|
trafficLightPosition: { x: 16, y: 18 },
|
|
vibrancy: 'sidebar' as const,
|
|
// 'active' keeps the vibrancy material stable when the window blurs;
|
|
// 'followWindow' washes the sidebar out behind an unfocused window.
|
|
visualEffectState: 'active' as const,
|
|
backgroundColor: '#00000000',
|
|
} : {}),
|
|
webPreferences: {
|
|
preload,
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
},
|
|
})
|
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url)
|
|
return { action: 'deny' }
|
|
})
|
|
if (process.platform === 'darwin') {
|
|
// macOS hides the traffic lights in fullscreen; the page drops its
|
|
// clearance for them off the html[data-fullscreen] flag this feeds.
|
|
const sendFullscreen = (): void => {
|
|
if (!window.isDestroyed()) window.webContents.send(DESKTOP_IPC.windowFullscreen, window.isFullScreen())
|
|
}
|
|
window.on('enter-full-screen', sendFullscreen)
|
|
window.on('leave-full-screen', sendFullscreen)
|
|
// Reloads and navigations re-register the preload listener; resend the
|
|
// current state so a fullscreen reload does not fall back to windowed CSS.
|
|
window.webContents.on('did-finish-load', sendFullscreen)
|
|
}
|
|
window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => {
|
|
const items: MenuItemConstructorOptions[] = []
|
|
if (isEditable) {
|
|
items.push(
|
|
{ role: 'undo', enabled: editFlags.canUndo },
|
|
{ role: 'redo', enabled: editFlags.canRedo },
|
|
{ type: 'separator' },
|
|
{ role: 'cut', enabled: editFlags.canCut },
|
|
{ role: 'copy', enabled: editFlags.canCopy },
|
|
{ role: 'paste', enabled: editFlags.canPaste },
|
|
{ type: 'separator' },
|
|
{ role: 'selectAll', enabled: editFlags.canSelectAll },
|
|
)
|
|
} else if (selectionText.length > 0) {
|
|
items.push({ role: 'copy', enabled: editFlags.canCopy })
|
|
}
|
|
// Empty accelerators suppress Electron's default shortcut labels for native roles.
|
|
if (items.length > 0) {
|
|
const messages = currentDesktopLocale().messages
|
|
Menu.buildFromTemplate(items.map(item => ({
|
|
...item,
|
|
...(process.platform === 'win32' && item.role !== undefined && item.role in messages
|
|
? { label: messages[item.role as keyof typeof messages] } : {}),
|
|
accelerator: '',
|
|
}))).popup({ window })
|
|
}
|
|
})
|
|
window.webContents.on('will-navigate', (event, url) => {
|
|
const destination = new URL(url)
|
|
const current = new URL(window.webContents.getURL())
|
|
if (destination.protocol !== `${SCHEME}:`
|
|
&& !(destination.protocol === 'http:' && destination.origin === current.origin)) {
|
|
event.preventDefault()
|
|
if (['http:', 'https:'].includes(destination.protocol)) void shell.openExternal(url)
|
|
}
|
|
})
|
|
return window
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
|
|
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
|
|
const resources = runtimeResources()
|
|
const paths = resolveDesktopPaths()
|
|
const development = !app.isPackaged
|
|
const activeProject = paths.profile
|
|
const manager = new DesktopProjectManager(paths, resources)
|
|
let quitting = false
|
|
let startup: Promise<void> | undefined
|
|
let workspaceRecovery: Promise<void> | undefined
|
|
let mainWindow: BrowserWindow | undefined
|
|
let shellInstallerOwnsQuit = false
|
|
let requireCleanStop = false
|
|
let updateStoppedHost = false
|
|
let updateStopFailure: DesktopHostUncleanExitError | undefined
|
|
let updateState: DesktopUpdateState = { phase: 'idle' }
|
|
let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
|
|
let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
|
|
let policyAuth: DesktopPolicyTestAuth | undefined
|
|
const isQuitting = (): boolean => quitting
|
|
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
|
|
const ordinaryDialogs = new Set<AbortController>()
|
|
const locale = resolveDesktopLocale(app.getLocale())
|
|
const messages = locale.messages
|
|
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale)
|
|
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
|
|
const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
|
|
const controller = new AbortController()
|
|
ordinaryDialogs.add(controller)
|
|
try {
|
|
if (mainWindow === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
|
|
return await updateDialog.show(mainWindow, { ...options, signal: controller.signal })
|
|
}
|
|
finally { ordinaryDialogs.delete(controller) }
|
|
}
|
|
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
|
|
const applicationUrl = `${SCHEME}://app/`
|
|
let hostUrl: string | undefined
|
|
let hostCookie: string | undefined
|
|
let injections: readonly unknown[] = []
|
|
const assertProductSender = (event: IpcMainInvokeEvent): void => {
|
|
assertDesktopSender(event, ['app'])
|
|
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
|
|
|| event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
|
throw new Error('dsh desktop: rejected IPC from an unowned renderer')
|
|
}
|
|
}
|
|
let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
|
|
const navigateMain = (url: string): Promise<void> => {
|
|
const window = mainWindow
|
|
if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve()
|
|
if (navigation?.window === window && navigation.url === url) return navigation.promise
|
|
const next = { window, url, promise: Promise.resolve() }
|
|
next.promise = window.loadURL(url).catch((error: unknown) => {
|
|
if (quitting || shuttingDown || window.isDestroyed() || navigation !== next
|
|
|| (error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED')) return
|
|
navigation = undefined
|
|
throw error
|
|
})
|
|
navigation = next
|
|
return next.promise
|
|
}
|
|
const backend = new DesktopBackendController((onFailure) => {
|
|
const hostInspectPort = developmentHostInspectPort(development)
|
|
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
|
hostInspectPort, process.env, onFailure,
|
|
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
|
|
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
|
|
resources)
|
|
return {
|
|
start: async () => {
|
|
const ready = await host.start()
|
|
hostCookie = await authenticateWebHost(ready.url)
|
|
hostUrl = ready.url
|
|
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
|
|
injections = ready.injections
|
|
},
|
|
stop: async () => {
|
|
try { await host.stop(requireCleanStop) }
|
|
catch (error) {
|
|
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
|
|
// Backend cleanup succeeded; installation still rejects the unsuccessful task teardown.
|
|
updateStopFailure = error
|
|
}
|
|
},
|
|
updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
|
|
}
|
|
}, (state) => {
|
|
if (state.phase === 'error') reportFatal(new Error(state.message))
|
|
})
|
|
|
|
const updateErrors = new WeakMap<DesktopUpdateState, Promise<void>>()
|
|
const showUpdateFailure = (state: DesktopUpdateState): Promise<void> => {
|
|
if (state.phase !== 'error') return Promise.resolve()
|
|
if (isMandatory()) { mandatoryUI?.sync(); return Promise.resolve() }
|
|
let shown = updateErrors.get(state)
|
|
if (shown === undefined) {
|
|
shown = ordinaryMessageBox({ type: 'error', title: messages.updateFailedTitle,
|
|
message: desktopUpdateErrorSummary(state, messages),
|
|
technicalDetails: state.technicalDetails ?? state.message ?? '' }).then(() => {})
|
|
updateErrors.set(state, shown)
|
|
}
|
|
return shown
|
|
}
|
|
const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
|
|
updateJournal?.state(state)
|
|
updateState = state
|
|
mandatoryUI?.sync()
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
|
|
}
|
|
if (state.phase === 'error' && state.failedOperation !== 'check') {
|
|
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
|
|
shellInstallerOwnsQuit = false
|
|
updateStoppedHost = false
|
|
if (restoreHost) {
|
|
// Only confirmed process exit permits replacement before another installation confirmation.
|
|
const hostReady = backend.start(async () => {})
|
|
startup = hostReady
|
|
const recovery = hostReady.then(async () => {
|
|
if (quitting) return
|
|
// A replacement Host can have a new port, cookie, or boot injections even at the same URL.
|
|
navigation = undefined
|
|
await navigateMain(applicationUrl)
|
|
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
|
|
})
|
|
workspaceRecovery = recovery
|
|
void recovery.catch(reportFatal).finally(() => {
|
|
if (startup === hostReady) startup = undefined
|
|
if (workspaceRecovery === recovery) workspaceRecovery = undefined
|
|
})
|
|
}
|
|
void showUpdateFailure(state).catch((error: unknown) => { console.error(error) })
|
|
}
|
|
return state
|
|
}
|
|
|
|
stopForRecovery = () => backend.close()
|
|
|
|
const reconcileBackend = (): Promise<void> => {
|
|
startup ??= (async () => {
|
|
await navigateMain(applicationUrl)
|
|
await backend.start(async () => {
|
|
await manager.applyRelease()
|
|
})
|
|
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
|
|
// The existing Web document resumes through the boot IPC response.
|
|
})().catch((error: unknown) => {
|
|
updateJournal?.action('workspace-failed')
|
|
reportFatal(error)
|
|
throw error
|
|
}).finally(() => { startup = undefined })
|
|
return startup
|
|
}
|
|
|
|
const updates = new DesktopUpdateCoordinator(
|
|
publishUpdate,
|
|
async () => {
|
|
await workspaceRecovery
|
|
await startup?.catch(() => undefined)
|
|
const host = backend.host
|
|
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable)
|
|
const active = await host.updateTasks('inspect')
|
|
const confirmation: Electron.MessageBoxOptions = {
|
|
type: active ? 'warning' : 'info', title: messages.updateTitle,
|
|
message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: updates.state.version ?? '' }),
|
|
detail: active ? messages.updateActiveTasksDetail
|
|
: messages.updateDownloadedDetail,
|
|
buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart],
|
|
defaultId: 1, cancelId: 1,
|
|
}
|
|
if (isMandatory()) {
|
|
if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
|
|
} else {
|
|
if (mainWindow === undefined) return false
|
|
const result = await updateDialog.show(mainWindow, confirmation)
|
|
if (result.response !== 0 || isMandatory()) return false
|
|
}
|
|
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable)
|
|
try {
|
|
const stillActive = await host.updateTasks('lock')
|
|
if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', messages.updateTasksChanged)
|
|
mandatoryUI?.preparingRestart(stillActive)
|
|
requireCleanStop = true
|
|
updateStopFailure = undefined
|
|
await backend.stop()
|
|
updateStoppedHost = true
|
|
// The backend's async cleanup callback can assign this after the reset above.
|
|
const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined
|
|
if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', messages.updateStopFailed, stopFailure.message)
|
|
updateJournal?.action('install-confirmed')
|
|
shellInstallerOwnsQuit = true
|
|
} catch (error) {
|
|
if (!updateStoppedHost) await host.updateTasks('unlock').catch((unlockError: unknown) => { console.error(unlockError) })
|
|
throw error
|
|
} finally {
|
|
requireCleanStop = false
|
|
}
|
|
return true
|
|
},
|
|
)
|
|
|
|
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
|
|
|
|
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
|
|
updateJournal?.action('download-requested')
|
|
const state = await updates.download(version)
|
|
if (state.phase !== 'ready' || quitting) return state
|
|
// Only a completed user-driven download opens this prompt; cancelling installation does not reopen it.
|
|
return updates.install(version)
|
|
}
|
|
|
|
protocol.handle(SCHEME, (request) => {
|
|
const url = new URL(request.url)
|
|
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
|
|
// Removing this route leaves those modal windows empty while their parent remains blocked.
|
|
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
|
|
if (url.hostname === 'app') {
|
|
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|
|
|| ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
|
|
return serveWebDocument(request, join(resources.dsh, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'dist'))
|
|
}
|
|
if (backend.host === undefined || hostUrl === undefined || hostCookie === undefined) {
|
|
return Promise.resolve(new Response(null, { status: 503 }))
|
|
}
|
|
return forwardWebRequest(request, hostUrl, hostCookie)
|
|
}
|
|
return Promise.resolve(new Response(null, { status: 404 }))
|
|
})
|
|
|
|
installDesktopDirectoryPicker(() => mainWindow)
|
|
|
|
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
|
|
assertDesktopSender(event, ['app'])
|
|
await startup
|
|
if (backend.host === undefined || hostUrl === undefined) throw new Error('Desktop Host is unavailable')
|
|
return { injections, streamBaseUrl: new URL(hostUrl).origin }
|
|
})
|
|
|
|
ipcMain.handle(DESKTOP_IPC.bootFailed, (event, message: unknown) => {
|
|
assertDesktopSender(event, ['app'])
|
|
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) {
|
|
throw new Error('dsh desktop: rejected startup failure from a non-primary frame')
|
|
}
|
|
if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text')
|
|
reportFatal(new Error(message))
|
|
})
|
|
|
|
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
|
|
if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
|
|
callback({})
|
|
return
|
|
}
|
|
const target = new URL(hostUrl)
|
|
const requested = new URL(details.url)
|
|
if (requested.host !== target.host) { callback({}); return }
|
|
const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
|
|
if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return }
|
|
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
|
|
})
|
|
|
|
// Only the main window may synchronize its palette with the native material.
|
|
ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
|
|
if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
|
|
if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
|
|
})
|
|
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
|
|
assertProductSender(event)
|
|
return presentDesktopUpdate(updates.state)
|
|
})
|
|
ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => {
|
|
assertProductSender(event)
|
|
await openUpdatePrompt()
|
|
})
|
|
|
|
let promptOperation: Promise<void> | undefined
|
|
let policyAuthenticationQueued = false
|
|
const openUpdatePrompt = (manual = false): Promise<void> => {
|
|
if (authenticationOperation !== undefined) {
|
|
policyAuth?.focus(); updateDialog.focus()
|
|
}
|
|
let failedOperation: 'check' | 'download' | 'install' = 'check'
|
|
promptOperation ??= Promise.resolve().then(async () => {
|
|
if (manual) updateJournal?.action('check-requested')
|
|
const joinedPolicyAuthentication = authenticationOperation !== undefined
|
|
if (joinedPolicyAuthentication) await authenticatePolicy()
|
|
if (isMandatory()) {
|
|
mandatoryUI?.focus()
|
|
if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
|
|
return
|
|
}
|
|
let controller: AbortController | undefined
|
|
let progress: Promise<unknown> | undefined
|
|
try {
|
|
let state = updates.state
|
|
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
|
|
controller = new AbortController()
|
|
ordinaryDialogs.add(controller)
|
|
progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
|
|
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
|
|
if (!joinedPolicyAuthentication) {
|
|
void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
|
|
}
|
|
state = await updateSchedule.check(true)
|
|
}
|
|
if (isMandatory()) { mandatoryUI?.focus(); return }
|
|
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
|
|
if (state.phase === 'idle') {
|
|
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
|
|
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
|
|
return
|
|
}
|
|
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
|
|
if (state.version !== undefined) {
|
|
failedOperation = 'install'
|
|
await showUpdateFailure(await updates.install(state.version))
|
|
}
|
|
return
|
|
}
|
|
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
|
|
if (manual) {
|
|
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
|
|
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
|
|
buttons: [messages.updateDownload], cancelId: 1 })
|
|
if (result.response !== 0) return
|
|
}
|
|
if (!isMandatory() && state.version !== undefined) {
|
|
controller?.abort()
|
|
failedOperation = 'download'
|
|
await showUpdateFailure(await downloadUpdate(state.version))
|
|
}
|
|
} finally {
|
|
controller?.abort()
|
|
if (controller !== undefined) ordinaryDialogs.delete(controller)
|
|
await progress
|
|
}
|
|
}).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation,
|
|
message: desktopErrorState(error).message }))
|
|
.finally(() => { promptOperation = undefined; flushQueuedPolicyAuthentication() })
|
|
return promptOperation
|
|
}
|
|
|
|
let authenticationOperation: Promise<DesktopPolicyState | undefined> | undefined
|
|
const authenticatePolicy = () => {
|
|
if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() }
|
|
authenticationOperation ??= runPolicyAuthentication().finally(() => { authenticationOperation = undefined })
|
|
return authenticationOperation
|
|
}
|
|
const flushQueuedPolicyAuthentication = (): void => {
|
|
if (!policyAuthenticationQueued || promptOperation !== undefined || authenticationOperation !== undefined
|
|
|| isMandatory() || quitting) return
|
|
policyAuthenticationQueued = false
|
|
void authenticatePolicy().catch((error: unknown) => { console.error(error) })
|
|
}
|
|
const queuePolicyAuthentication = (): void => {
|
|
if (authenticationOperation !== undefined) {
|
|
policyAuth?.focus(); updateDialog.focus()
|
|
return
|
|
}
|
|
policyAuthenticationQueued = true
|
|
flushQueuedPolicyAuthentication()
|
|
}
|
|
const runPolicyAuthentication = async () => {
|
|
if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
|
|
const parent = mandatoryUI?.confirmationWindow ?? mainWindow
|
|
if (parent === undefined) return undefined
|
|
const consent = await updateDialog.show(parent, { type: 'info', title: messages.policyLoginTitle,
|
|
message: messages.policyLoginRequired, buttons: [messages.policyLogin, messages.later], cancelId: 1 })
|
|
if (consent.response !== 0 || isQuitting()) return undefined
|
|
const outcome = await policyAuth.login()
|
|
if (isQuitting() || outcome === 'cancelled') return undefined
|
|
if (outcome === 'failed') {
|
|
await updateDialog.show(parent, { type: 'error', title: messages.policyLoginTitle,
|
|
message: messages.policyLoginFailed, buttons: [messages.updateAcknowledge], cancelId: 0 })
|
|
return undefined
|
|
}
|
|
// Drain a pre-login request before asking the server to evaluate the new cookies.
|
|
await mandatoryPolicy.check('login-return')
|
|
if (isQuitting()) return undefined
|
|
return mandatoryPolicy.check('login-return', true)
|
|
}
|
|
|
|
const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => {
|
|
if (authenticationOperation !== undefined) return authenticatePolicy()
|
|
const policy = await mandatoryPolicy?.check('manual', true)
|
|
if (policy?.error !== 'authentication-required') return policy
|
|
if (authentication === 'immediate') return authenticatePolicy()
|
|
queuePolicyAuthentication()
|
|
return policy
|
|
}
|
|
|
|
const automaticCheck = (): void => {
|
|
if (!quitting) void mandatoryPolicy?.check('foreground-or-resume').catch((error: unknown) => { console.error(error) })
|
|
if (!quitting) void updateSchedule.check().catch((error: unknown) => { console.error(error) })
|
|
}
|
|
powerMonitor.on('resume', automaticCheck)
|
|
app.on('will-quit', () => {
|
|
updateSchedule.dispose()
|
|
powerMonitor.off('resume', automaticCheck)
|
|
updates.dispose()
|
|
})
|
|
|
|
app.setAboutPanelOptions({
|
|
applicationName: 'DeepSeek Harness',
|
|
applicationVersion: app.getVersion(),
|
|
// The release has no separate build number; omit Electron's bundle version.
|
|
version: '',
|
|
copyright: '',
|
|
iconPath: development ? join(app.getAppPath(), 'resources', 'icon-windows.png')
|
|
: join(process.resourcesPath, 'icon.png'),
|
|
})
|
|
// A custom application menu replaces Electron's default menu, so macOS needs
|
|
// its standard menus and application hide commands declared explicitly.
|
|
// Keep app.name stable: Electron derives its default userData directory from it.
|
|
const darwin = process.platform === 'darwin'
|
|
const platformMenus: MenuItemConstructorOptions[] = darwin
|
|
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
|
|
: [{ role: 'editMenu' }]
|
|
const hideCommands: MenuItemConstructorOptions[] = darwin
|
|
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
|
|
{ role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications },
|
|
{ role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
|
|
: []
|
|
const applicationItems = (): MenuItemConstructorOptions[] => [
|
|
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
|
|
{ type: 'separator' },
|
|
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
|
|
{ type: 'separator' },
|
|
...hideCommands,
|
|
{ role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication }
|
|
: process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
|
|
]
|
|
Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
|
|
label: darwin ? app.name : currentDesktopLocale().messages.application,
|
|
submenu: applicationItems(),
|
|
}, ...platformMenus]))
|
|
|
|
if (process.platform === 'win32') {
|
|
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
|
|
assertDesktopSender(event, ['app'])
|
|
if (mainWindow === undefined || event.sender !== mainWindow.webContents
|
|
|| event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error('desktop menu: rejected sender')
|
|
if ((name !== 'application' && name !== 'edit')
|
|
|| typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)
|
|
|| x < 0 || y < 0 || x > 100_000 || y > 100_000) throw new Error('desktop menu: invalid popup request')
|
|
const window = mainWindow
|
|
// Editor-owned history listens to key events rather than Chromium's native undo stack.
|
|
const editItem = (label: string, keyCode: string, modifiers: Array<'control'>, accelerator?: string): MenuItemConstructorOptions => ({
|
|
label,
|
|
...(accelerator === undefined ? {} : { accelerator }),
|
|
click: () => {
|
|
window.webContents.focus()
|
|
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
|
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
|
},
|
|
})
|
|
const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
|
|
editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'),
|
|
editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'),
|
|
{ type: 'separator' },
|
|
editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'),
|
|
editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'),
|
|
editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'),
|
|
editItem(currentDesktopLocale().messages.delete, 'Delete', []),
|
|
{ type: 'separator' },
|
|
editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'),
|
|
]
|
|
const zoom = mainWindow.webContents.getZoomFactor()
|
|
return new Promise<void>((resolve) => {
|
|
Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve })
|
|
})
|
|
})
|
|
ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => {
|
|
if (mainWindow === undefined || event.sender !== mainWindow.webContents
|
|
|| event.senderFrame !== mainWindow.webContents.mainFrame) return
|
|
if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return
|
|
if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) {
|
|
windowsLanguage = language
|
|
}
|
|
// Empty colors precede client stylesheet installation; only CSS color values cross IPC.
|
|
const validColor = (value: unknown): value is string => typeof value === 'string'
|
|
&& /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value)
|
|
if (validColor(color) && validColor(symbolColor)) mainWindow.setTitleBarOverlay({ color, symbolColor })
|
|
})
|
|
}
|
|
|
|
const createMainWindow = (): BrowserWindow => {
|
|
const window = createWindow(appPreload, true, true)
|
|
mainWindow = window
|
|
window.on('focus', automaticCheck)
|
|
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
|
|
window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
|
|
if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
|
|
reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`))
|
|
}
|
|
})
|
|
window.webContents.on('preload-error', (_event, _path, error) => {
|
|
if (!quitting && !window.isDestroyed()) reportFatal(error)
|
|
})
|
|
window.webContents.on('render-process-gone', (_event, details) => {
|
|
navigation = undefined
|
|
if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') {
|
|
reportFatal(new Error(`Desktop renderer exited: ${details.reason}`))
|
|
}
|
|
})
|
|
return window
|
|
}
|
|
focusPrimaryWindow = () => {
|
|
if (quitting) return
|
|
if (isMandatory()) { mandatoryUI?.focus(); return }
|
|
const window = mainWindow
|
|
if (window === undefined || window.isDestroyed()) {
|
|
try { createMainWindow() } catch (error) { reportFatal(error); return }
|
|
void navigateMain(applicationUrl).catch(reportFatal)
|
|
return
|
|
}
|
|
if (window.isMinimized()) window.restore()
|
|
window.show()
|
|
window.focus()
|
|
}
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
|
|
})
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit()
|
|
})
|
|
app.on('before-quit', (event) => {
|
|
shuttingDown = true
|
|
updateJournal?.action('quit-requested')
|
|
if (shellInstallerOwnsQuit) {
|
|
updateDialog.dispose()
|
|
mandatoryUI?.dispose()
|
|
return
|
|
}
|
|
if (quitting) return
|
|
event.preventDefault()
|
|
quitting = true
|
|
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
|
|
updateSchedule.dispose()
|
|
updateDialog.dispose()
|
|
mandatoryUI?.dispose()
|
|
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close()])
|
|
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
|
|
})
|
|
|
|
mainWindow = createMainWindow()
|
|
const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
|
|
if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest')
|
|
const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
|
|
const policyInput: unknown = app.isPackaged
|
|
? ('dshMandatoryUpdatePolicy' in manifest ? manifest.dshMandatoryUpdatePolicy : undefined)
|
|
: developmentPolicy === undefined ? undefined : JSON.parse(developmentPolicy) as unknown
|
|
const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged)
|
|
if (policyConfig !== undefined) {
|
|
if (policyConfig.authentication === 'feishu-test') {
|
|
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, policyConfig.allowedAuthOrigins, locale,
|
|
() => mandatoryUI?.confirmationWindow ?? mainWindow,
|
|
(event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
|
|
}
|
|
const bundleId = app.isPackaged
|
|
? ('dshDesktopAppId' in manifest ? manifest.dshDesktopAppId : undefined)
|
|
: process.env.DSH_DESKTOP_APP_ID
|
|
if (typeof bundleId !== 'string' || bundleId.trim() === '') throw new Error('desktop policy: missing application bundle ID')
|
|
if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform')
|
|
let wasBlocking = false
|
|
mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
|
|
platform: process.platform === 'win32' ? 'desktop-win' : 'desktop-mac', arch: process.arch as 'x64' | 'arm64',
|
|
version: app.getVersion(), bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
|
|
bundleId, locale: locale.id,
|
|
}, (state) => {
|
|
if (state.error !== 'authentication-required') policyAuthenticationQueued = false
|
|
if (state.blocking) {
|
|
for (const controller of ordinaryDialogs) controller.abort()
|
|
if (!wasBlocking) updateDialog.cancel()
|
|
}
|
|
mandatoryUI?.sync()
|
|
if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) })
|
|
wasBlocking = state.blocking
|
|
}, policyAuth?.request)
|
|
const policy = mandatoryPolicy
|
|
mandatoryUI = new DesktopMandatoryUpdateWindow({
|
|
preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
|
|
allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
|
|
policy: () => policy.state, update: () => updates.state,
|
|
refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) },
|
|
download: downloadUpdate, install: version => updates.install(version),
|
|
})
|
|
void mandatoryPolicy.check('launch').then((state) => {
|
|
if (app.isPackaged && state.error === 'authentication-required' && !isQuitting()) queuePolicyAuthentication()
|
|
}).catch((error: unknown) => { console.error(error) })
|
|
}
|
|
automaticCheck()
|
|
await reconcileBackend().catch(() => undefined)
|
|
// Window lifecycle callbacks run while backend startup is pending.
|
|
if (isQuitting()) return
|
|
const window = currentMainWindow()
|
|
if (window !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
|
|
window.webContents.openDevTools({ mode: 'detach' })
|
|
}
|
|
publishUpdate(updateState)
|
|
}
|
|
|
|
const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
|
|
|
|
if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
console.error(error)
|
|
const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE
|
|
if (diagnosticFile !== undefined) {
|
|
await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
|
|
}
|
|
reportFatal(error)
|
|
}).catch((error: unknown) => {
|
|
console.error(error)
|
|
app.exit(1)
|
|
})
|