main.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
  2. import { readFile, writeFile } from 'node:fs/promises'
  3. import { extname, join, normalize, resolve, sep } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import {
  6. app,
  7. BrowserWindow,
  8. dialog,
  9. ipcMain,
  10. Menu,
  11. protocol,
  12. type IpcMainInvokeEvent,
  13. } from 'electron'
  14. import { resolveDesktopPaths } from './paths.ts'
  15. import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
  16. import { DesktopHostProcess } from './host-process.ts'
  17. import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
  18. import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
  19. import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
  20. import { claimDesktopSingleInstance } from './single-instance.ts'
  21. import { DesktopUpdateCoordinator } from './update-coordinator.ts'
  22. import { desktopErrorState } from './startup-error.ts'
  23. import { startupFailureDocument } from './startup-document.ts'
  24. const SCHEME = 'dsh-app'
  25. let focusPrimaryWindow = (): void => {}
  26. type RecoveryAction = 'restart' | 'plugins' | 'reset'
  27. let profileRecoveryAvailable = (): boolean => false
  28. const emergencyPages = new WeakMap<BrowserWindow, { url: string; message: string; busy: boolean }>()
  29. let recoverApplication = (action: RecoveryAction): Promise<void> => {
  30. if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application'))
  31. app.relaunch()
  32. app.quit()
  33. return Promise.resolve()
  34. }
  35. async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
  36. const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable())
  37. const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
  38. emergencyPages.set(window, { url, message, busy: false })
  39. await window.loadURL(url)
  40. }
  41. protocol.registerSchemesAsPrivileged([{
  42. scheme: SCHEME,
  43. privileges: {
  44. standard: true,
  45. secure: true,
  46. supportFetchAPI: true,
  47. corsEnabled: false,
  48. stream: true,
  49. codeCache: true,
  50. },
  51. }])
  52. const MIME: Readonly<Record<string, string>> = {
  53. '.css': 'text/css; charset=utf-8',
  54. '.html': 'text/html; charset=utf-8',
  55. '.js': 'text/javascript; charset=utf-8',
  56. '.svg': 'image/svg+xml',
  57. }
  58. interface RuntimeResources {
  59. readonly node: string
  60. readonly pnpm: string
  61. readonly dsh: string
  62. readonly profileResolution?: 'runtime'
  63. }
  64. function runtimeResources(): RuntimeResources {
  65. const development = !app.isPackaged
  66. const node = development
  67. ? process.env.DSH_DESKTOP_NODE_BINARY
  68. ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
  69. : process.execPath
  70. const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
  71. ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
  72. const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
  73. ?? (development ? join(process.resourcesPath, 'dsh') : join(app.getAppPath(), 'dsh'))
  74. return { node, pnpm, dsh, ...(development ? {} : { profileResolution: 'runtime' }) }
  75. }
  76. function developmentHostInspectPort(enabled: boolean): number | undefined {
  77. const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
  78. if (!enabled || configured === undefined || configured === '') return undefined
  79. const port = Number(configured)
  80. if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
  81. throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535')
  82. }
  83. return port
  84. }
  85. function createWindow(preload: string, show = false): BrowserWindow {
  86. const window = new BrowserWindow({
  87. width: 1280,
  88. height: 840,
  89. minWidth: 880,
  90. minHeight: 600,
  91. show,
  92. webPreferences: {
  93. preload,
  94. nodeIntegration: false,
  95. contextIsolation: true,
  96. sandbox: true,
  97. webSecurity: true,
  98. },
  99. })
  100. window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
  101. window.webContents.on('will-navigate', (event, url) => {
  102. if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault()
  103. const page = emergencyPages.get(window)
  104. if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return
  105. const action = new URL(url)
  106. if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return
  107. if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return
  108. page.busy = true
  109. void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => {
  110. if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`)
  111. }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false })
  112. })
  113. return window
  114. }
  115. function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
  116. const senderFrame = event.senderFrame
  117. if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
  118. const url = new URL(senderFrame.url)
  119. if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
  120. throw new Error('dsh desktop: rejected IPC from an unowned renderer')
  121. }
  122. }
  123. async function serveShellAsset(request: Request): Promise<Response> {
  124. if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
  125. const root = resolve(app.getAppPath(), 'renderer')
  126. const url = new URL(request.url)
  127. let pathname: string
  128. try {
  129. pathname = decodeURIComponent(url.pathname)
  130. } catch {
  131. return new Response(null, { status: 400 })
  132. }
  133. const target = resolve(normalize(join(root, pathname)))
  134. if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 })
  135. try {
  136. const body = request.method === 'HEAD' ? null : await readFile(target)
  137. return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } })
  138. } catch {
  139. return new Response(null, { status: 404 })
  140. }
  141. }
  142. async function main(): Promise<void> {
  143. const resources = runtimeResources()
  144. const paths = resolveDesktopPaths()
  145. const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project')
  146. const activeProject = development ?? paths.profile
  147. const manager = new DesktopProjectManager(paths, resources)
  148. profileRecoveryAvailable = () => development === undefined && manager.canRecoverProfile()
  149. let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
  150. let quitting = false
  151. let startup: Promise<void> | undefined
  152. let mainWindow: BrowserWindow | undefined
  153. let pluginWindow: BrowserWindow | undefined
  154. let shellInstallerOwnsQuit = false
  155. let updateState: DesktopUpdateState = { phase: 'idle' }
  156. const locale = resolveDesktopLocale(app.getLocale())
  157. const messages = locale.messages
  158. const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
  159. const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
  160. const startupUrl = `${SCHEME}://shell/startup.html`
  161. const applicationUrl = `${SCHEME}://app/index.html`
  162. let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
  163. let emergencyDocument = false
  164. const showEmergencyError = async (error: unknown): Promise<void> => {
  165. if (quitting || emergencyDocument) return
  166. emergencyDocument = true
  167. const diagnostic = desktopErrorState(error).message
  168. pageError = { phase: 'error', message: diagnostic }
  169. if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
  170. }
  171. const navigateMain = (url: string): Promise<void> => {
  172. const window = mainWindow
  173. if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
  174. if (navigation?.window === window && navigation.url === url) return navigation.promise
  175. const next = { window, url, promise: Promise.resolve() }
  176. next.promise = window.loadURL(url).catch((error: unknown) => {
  177. if (quitting || window.isDestroyed() || navigation !== next) return
  178. navigation = undefined
  179. throw error
  180. })
  181. navigation = next
  182. return next.promise
  183. }
  184. const backendState = (): DesktopBackendState => {
  185. const state = pageError ?? backend.state
  186. return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state
  187. }
  188. const publishBackend = (state: DesktopBackendState): void => {
  189. for (const window of BrowserWindow.getAllWindows()) {
  190. window.webContents.send(DESKTOP_IPC.backendState, state)
  191. }
  192. }
  193. const backend = new DesktopBackendController((onFailure) => {
  194. if (development === undefined) manager.assertProfileRuntime(activeProject)
  195. const hostInspectPort = developmentHostInspectPort(development !== undefined)
  196. const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject,
  197. hostInspectPort, process.env, onFailure)
  198. return {
  199. start: () => host.start(),
  200. stop: () => host.stop(),
  201. fetch: (request: Request) => host.fetch(request),
  202. }
  203. }, (state) => {
  204. if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
  205. publishBackend(backendState())
  206. if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
  207. })
  208. const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
  209. updateState = state
  210. for (const window of BrowserWindow.getAllWindows()) {
  211. window.webContents.send(DESKTOP_IPC.updatesState, state)
  212. }
  213. return state
  214. }
  215. const hooks: DesktopProjectHooks = {
  216. beforeChange: () => backend.stop(),
  217. afterChange: () => backend.start(async () => {}),
  218. }
  219. recoverApplication = async (action): Promise<void> => {
  220. await startup?.catch(() => undefined)
  221. await backend.stop()
  222. if (action === 'restart') {
  223. app.relaunch()
  224. app.quit()
  225. return
  226. }
  227. if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice)
  228. if (action === 'reset') await manager.resetConfiguration(hooks)
  229. else await manager.mutate({ type: 'plugins-disable-all' }, hooks)
  230. emergencyDocument = false
  231. pageError = undefined
  232. navigation = undefined
  233. await navigateMain(applicationUrl)
  234. }
  235. const showStartupError = async (error: unknown): Promise<void> => {
  236. if (quitting) return
  237. pageError = desktopErrorState(error)
  238. try { await navigateMain(startupUrl) }
  239. catch (navigationError) {
  240. await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
  241. }
  242. publishBackend(backendState())
  243. }
  244. const reconcileBackend = (): Promise<void> => {
  245. startup ??= (async () => {
  246. pageError = undefined
  247. await navigateMain(startupUrl)
  248. await backend.start(async () => {
  249. if (development === undefined) {
  250. await manager.applyRelease()
  251. }
  252. })
  253. if (backend.host !== undefined) await navigateMain(applicationUrl)
  254. })().catch(async (error: unknown) => {
  255. await showStartupError(error)
  256. throw error
  257. }).finally(() => { startup = undefined })
  258. return startup
  259. }
  260. const updates = new DesktopUpdateCoordinator(
  261. publishUpdate,
  262. async () => {
  263. shellInstallerOwnsQuit = true
  264. await backend.stop()
  265. },
  266. )
  267. protocol.handle(SCHEME, (request) => {
  268. const url = new URL(request.url)
  269. if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
  270. if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
  271. void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
  272. .catch((error: unknown) => { console.error(error) })
  273. }
  274. return response
  275. })
  276. if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 }))
  277. const active = backend.host
  278. if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 }))
  279. return active.fetch(request)
  280. })
  281. const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
  282. assertDesktopSender(event, ['shell'])
  283. if (development !== undefined) {
  284. throw new Error('dsh desktop: plugin package changes require a packaged application')
  285. }
  286. await startup?.catch(() => undefined)
  287. pageError = undefined
  288. await navigateMain(startupUrl)
  289. try {
  290. await manager.mutate(mutation, hooks)
  291. await navigateMain(applicationUrl)
  292. } catch (error) {
  293. await showStartupError(error)
  294. throw error
  295. }
  296. }
  297. ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
  298. assertDesktopSender(event, ['shell'])
  299. return locale
  300. })
  301. ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => {
  302. assertDesktopSender(event, ['shell'])
  303. if (development !== undefined) return []
  304. return manager.listPlugins()
  305. })
  306. ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => {
  307. if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string')
  308. return mutate(event, { type: 'plugin-add', spec })
  309. })
  310. ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => {
  311. if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string')
  312. return mutate(event, { type: 'plugin-remove', name })
  313. })
  314. ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => {
  315. if (typeof name !== 'string' || typeof version !== 'string') {
  316. throw new Error('dsh desktop: plugin name and version must be strings')
  317. }
  318. return mutate(event, { type: 'plugin-update', name, version })
  319. })
  320. ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => {
  321. if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
  322. return mutate(event, { type: 'plugin-toggle', name, enabled })
  323. })
  324. ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
  325. ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
  326. assertDesktopSender(event, ['shell'])
  327. return backendState()
  328. })
  329. ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
  330. assertDesktopSender(event, ['shell'])
  331. await reconcileBackend()
  332. focusPrimaryWindow()
  333. })
  334. ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
  335. assertDesktopSender(event, ['shell'])
  336. try {
  337. await recoverApplication('restart')
  338. } catch (error) {
  339. await showStartupError(error)
  340. }
  341. })
  342. ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => {
  343. assertDesktopSender(event, ['shell'])
  344. if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application')
  345. const failure = backendState()
  346. if (failure.phase !== 'error') {
  347. throw new Error('Desktop profile reset requires a startup failure')
  348. }
  349. await startup?.catch(() => undefined)
  350. try {
  351. await recoverApplication('reset')
  352. } catch (error) {
  353. await showStartupError(error)
  354. }
  355. })
  356. ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
  357. assertDesktopSender(event, ['shell'])
  358. return updates.check()
  359. })
  360. ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
  361. assertDesktopSender(event, ['shell'])
  362. await updates.install()
  363. })
  364. const checkAndPrompt = async (manual: boolean): Promise<void> => {
  365. const state = await updates.check()
  366. if (state.phase === 'error') {
  367. if (manual) {
  368. await dialog.showMessageBox({
  369. type: 'error',
  370. title: messages.updateCheckFailedTitle,
  371. message: state.message ?? messages.unknownError,
  372. })
  373. }
  374. return
  375. }
  376. if (state.phase !== 'available') {
  377. if (manual) {
  378. await dialog.showMessageBox({
  379. type: 'info',
  380. title: messages.updateCheckTitle,
  381. message: state.message ?? messages.updateCurrent,
  382. })
  383. }
  384. return
  385. }
  386. const result = await dialog.showMessageBox({
  387. type: 'info',
  388. title: messages.updateTitle,
  389. message: messages.updateAvailable,
  390. detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
  391. buttons: [messages.installAndRestart, messages.later],
  392. defaultId: 0,
  393. cancelId: 1,
  394. })
  395. if (result.response !== 0) return
  396. const installed = await updates.install()
  397. if (installed.phase === 'error') {
  398. await dialog.showMessageBox({
  399. type: 'error',
  400. title: messages.updateFailedTitle,
  401. message: installed.message ?? messages.unknownError,
  402. })
  403. }
  404. }
  405. const openPluginWindow = (): void => {
  406. if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
  407. pluginWindow.focus()
  408. return
  409. }
  410. pluginWindow = createWindow(managementPreload)
  411. pluginWindow.setSize(900, 620)
  412. pluginWindow.setTitle(messages.pluginWindowTitle)
  413. pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
  414. pluginWindow.once('closed', () => { pluginWindow = undefined })
  415. void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
  416. }
  417. Menu.setApplicationMenu(Menu.buildFromTemplate([{
  418. label: process.platform === 'darwin' ? app.name : messages.application,
  419. submenu: [
  420. {
  421. label: development === undefined ? messages.pluginsMenu : messages.pluginsMenuPackagedOnly,
  422. accelerator: 'CmdOrCtrl+,',
  423. enabled: development === undefined,
  424. click: openPluginWindow,
  425. },
  426. { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
  427. { type: 'separator' },
  428. { role: 'quit' },
  429. ],
  430. }]))
  431. const createMainWindow = (): BrowserWindow => {
  432. const window = createWindow(appPreload, true)
  433. mainWindow = window
  434. window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
  435. window.webContents.on('preload-error', (_event, _path, error) => {
  436. void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
  437. })
  438. window.webContents.on('render-process-gone', (_event, details) => {
  439. navigation = undefined
  440. emergencyDocument = false
  441. void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
  442. .catch((failure: unknown) => { console.error(failure) })
  443. })
  444. return window
  445. }
  446. focusPrimaryWindow = () => {
  447. const window = mainWindow
  448. if (window === undefined || window.isDestroyed()) {
  449. createMainWindow()
  450. void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl)
  451. .catch((error: unknown) => { console.error(error) })
  452. return
  453. }
  454. if (window.isMinimized()) window.restore()
  455. window.show()
  456. window.focus()
  457. }
  458. app.on('activate', () => {
  459. if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
  460. })
  461. app.on('window-all-closed', () => {
  462. if (process.platform !== 'darwin') app.quit()
  463. })
  464. app.on('before-quit', (event) => {
  465. if (shellInstallerOwnsQuit || quitting) return
  466. event.preventDefault()
  467. quitting = true
  468. void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
  469. })
  470. mainWindow = createMainWindow()
  471. await reconcileBackend().catch(() => undefined)
  472. // Window lifecycle callbacks run while backend startup is pending.
  473. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  474. if (quitting) return
  475. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  476. if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
  477. mainWindow.webContents.openDevTools({ mode: 'detach' })
  478. }
  479. publishUpdate(updateState)
  480. setTimeout(() => { void checkAndPrompt(false) }, 10_000)
  481. }
  482. const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
  483. if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => {
  484. const message = error instanceof Error ? error.message : String(error)
  485. console.error(error)
  486. const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE
  487. if (diagnosticFile !== undefined) {
  488. await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
  489. }
  490. const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true)
  491. window.once('closed', () => { app.quit() })
  492. await showEmergencyDocument(window, message)
  493. }).catch((error: unknown) => {
  494. console.error(error)
  495. app.exit(1)
  496. })