update-dialog.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. /** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
  2. import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
  3. import type { DesktopLocale } from './locale.ts'
  4. import { createUpdateOverlay } from './update-overlay.ts'
  5. /** Channels available only to the isolated update-dialog document. */
  6. export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', respond: 'dsh-update-dialog:respond' } as const
  7. /** Text and choices supplied by the main process, never by product documents. */
  8. export interface UpdateDialogView {
  9. readonly locale: string
  10. readonly title: string
  11. readonly message: string
  12. readonly detail: string
  13. readonly buttons: readonly string[]
  14. readonly cancelId: number
  15. readonly closeLabel: string
  16. readonly technicalDetails: string
  17. readonly technicalDetailsLabel: string
  18. }
  19. /** Electron message options with separately expandable, main-owned diagnostics. */
  20. export interface UpdateDialogOptions extends MessageBoxOptions {
  21. readonly technicalDetails?: string
  22. }
  23. /** The document can select only a displayed response index. */
  24. export interface UpdateDialogApi {
  25. status(): Promise<UpdateDialogView>
  26. respond(index: number): Promise<void>
  27. }
  28. const page = 'dsh-app://shell/update-dialog.html'
  29. /** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */
  30. export class DesktopUpdateDialog {
  31. private disposed = false
  32. private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => void } | undefined
  33. /** Focus the current explanation or confirmation without replacing it or granting permission. */
  34. focus(): void { this.active?.window.focus() }
  35. /**
  36. * @param preload - Bundled isolated preload.
  37. * @param locale - Shell-owned copy.
  38. */
  39. constructor(private readonly preload: string, private readonly locale: DesktopLocale) {
  40. ipcMain.handle(UPDATE_DIALOG_IPC.status, event => this.owned(event).view)
  41. ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => {
  42. const active = this.owned(event)
  43. if (typeof index !== 'number' || !Number.isInteger(index)
  44. || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) {
  45. throw new Error('desktop update: invalid dialog response')
  46. }
  47. active.finish(index)
  48. })
  49. }
  50. /**
  51. * @param parent - Window blocked by this confirmation.
  52. * @param options - Main-owned localized content, response choices, and optional cancellation signal.
  53. * @returns A displayed response, or the cancel response when closed, replaced, aborted, or unable to load.
  54. */
  55. show(parent: BrowserWindow, options: UpdateDialogOptions): Promise<MessageBoxReturnValue> {
  56. this.cancel()
  57. const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge]
  58. const cancelId = options.cancelId ?? buttons.length - 1
  59. if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) {
  60. return Promise.resolve({ response: cancelId, checkboxChecked: false })
  61. }
  62. const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle)
  63. const view: UpdateDialogView = { locale: this.locale.id, title: options.title ?? '', message: options.message,
  64. detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose,
  65. technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails }
  66. return new Promise((resolve) => {
  67. const abort = (): void => { finish(cancelId) }
  68. const finish = (response: number): void => {
  69. if (this.active?.window !== window) return
  70. this.active = undefined
  71. options.signal?.removeEventListener('abort', abort)
  72. if (!window.isDestroyed()) window.destroy()
  73. resolve({ response, checkboxChecked: false })
  74. }
  75. this.active = { window, view, finish }
  76. options.signal?.addEventListener('abort', abort, { once: true })
  77. window.once('closed', abort)
  78. window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
  79. window.webContents.once('render-process-gone', abort)
  80. void window.loadURL(page).catch(abort)
  81. })
  82. }
  83. /** Cancel the displayed prompt without authorizing any operation. */
  84. cancel(): void { this.active?.finish(this.active.view.cancelId) }
  85. /** Close the document and detach its private IPC handlers. */
  86. dispose(): void {
  87. if (this.disposed) return
  88. this.disposed = true
  89. this.cancel()
  90. ipcMain.removeHandler(UPDATE_DIALOG_IPC.status)
  91. ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond)
  92. }
  93. private owned(event: IpcMainInvokeEvent): NonNullable<DesktopUpdateDialog['active']> {
  94. const active = this.active
  95. if (active === undefined || event.sender !== active.window.webContents
  96. || event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) {
  97. throw new Error('desktop update: rejected unowned dialog renderer')
  98. }
  99. return active
  100. }
  101. }