From 299101077a22225a3fa09a705c81d98c057b8130 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 13:22:20 +0800 Subject: [PATCH 1/4] fix(desktop): keep update dialog backdrop stationary across prompts --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/update-dialog.css | 6 +- apps/desktop/renderer/update-dialog.js | 20 ++++- apps/desktop/src/main.ts | 71 ++++++++++-------- apps/desktop/src/preload-update-dialog.ts | 9 ++- apps/desktop/src/update-dialog.ts | 75 +++++++++++++------ apps/desktop/src/update-overlay.ts | 16 +++- .../desktop/tests/fixtures/update-dialogs.mjs | 2 + apps/desktop/tests/main-startup.spec.ts | 7 +- apps/desktop/tests/update-dialog.spec.ts | 65 +++++++++++++++- .../tests/update-error-renderer.spec.ts | 20 ++++- 13 files changed, 224 insertions(+), 75 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 909bef683c..50d6cc47c4 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: c2a1f4df103d5fe74f4a25aa1bcfc1f8e38d3b1e -README.zh.md: 9dda03b4b40a7b46bf5e0f1404f77d73d397bfa4 +README.md: 94abdbb01028639da51755a5084a063ac4cf7f84 +README.zh.md: 0a7f5a82169f2b8b2a74910d2c912a33a7ff4c8f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c2a1f4df10..94abdbb010 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -263,7 +263,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9dda03b4b4..0a7f5a8216 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -264,7 +264,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗仅替换卡片内容,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index 043c0d0cbc..5ceffbb363 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -5,7 +5,8 @@ background: transparent; } * { box-sizing: border-box; } -body { margin: 0; height: 100vh; display: grid; place-items: center; background: rgb(0 0 0 / 24%); } +body { opacity: 0; transition: opacity 150ms ease; margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } +body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } main:focus { outline: none; } @@ -24,3 +25,6 @@ summary { cursor: pointer; color: #5b6472; } pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } button:disabled { opacity: 0.5; cursor: default; } [hidden] { display: none !important; } +body.visible { opacity: 1; } +@starting-style { body.visible { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { body { transition: none; } } diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index baa4ac75f6..fab2abf05e 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -5,7 +5,7 @@ let responding = false function respond(index) { if (responding || view === undefined) return responding = true - void api.respond(index).catch(() => { responding = false }) + void api.respond(view.revision, index).catch(() => { responding = false }) } document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) }) document.addEventListener('keydown', event => { @@ -18,7 +18,14 @@ document.addEventListener('keydown', event => { event.preventDefault() controls[next].focus() }) -void api.status().then(state => { +function render(state) { + if (state === null) { + responding = true + document.body.classList.remove('visible') + return + } + if (view !== undefined && state.revision <= view.revision) return + responding = false view = state document.documentElement.lang = state.locale document.title = state.title @@ -29,6 +36,8 @@ void api.status().then(state => { document.getElementById('technical-details').hidden = state.technicalDetails === '' document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel document.getElementById('technical-details-content').textContent = state.technicalDetails + document.getElementById('technical-details').open = false + document.getElementById('actions').replaceChildren() for (const [index, label] of state.buttons.entries()) { const button = document.createElement('button') button.type = 'button' @@ -38,5 +47,10 @@ void api.status().then(state => { document.getElementById('actions').append(button) } document.querySelector('main').hidden = false + document.body.classList.add('visible') document.getElementById('dialog').focus() -}) +} +let received = false +const unsubscribe = api.subscribe(state => { received = true; render(state) }) +window.addEventListener('pagehide', unsubscribe, { once: true }) +void api.status().then(state => { if (!received) render(state) }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5c21833b0e..f9bfb78409 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -474,43 +474,50 @@ async function main(): Promise { if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) return } - let state = updates.state - if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { - const controller = new AbortController() - ordinaryDialogs.add(controller) - const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle, - message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal }) - try { + let controller: AbortController | undefined + let progress: Promise | 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) - } finally { controller.abort(); ordinaryDialogs.delete(controller); await progress } - } - 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) { - failedOperation = 'download' - await showUpdateFailure(await downloadUpdate(state.version)) + 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 })) diff --git a/apps/desktop/src/preload-update-dialog.ts b/apps/desktop/src/preload-update-dialog.ts index 65cf46fcd4..37a5757aac 100644 --- a/apps/desktop/src/preload-update-dialog.ts +++ b/apps/desktop/src/preload-update-dialog.ts @@ -3,7 +3,12 @@ import { contextBridge, ipcRenderer } from 'electron' import { UPDATE_DIALOG_IPC, type UpdateDialogApi, type UpdateDialogView } from './update-dialog.ts' const api: UpdateDialogApi = { - status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise, - respond: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise, + status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise, + respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise, + subscribe: (listener) => { + const receive = (_event: Electron.IpcRendererEvent, view: UpdateDialogView | null): void => { listener(view) } + ipcRenderer.on(UPDATE_DIALOG_IPC.changed, receive) + return () => { ipcRenderer.removeListener(UPDATE_DIALOG_IPC.changed, receive) } + }, } if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api) diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index f48f70d075..c307bda9e4 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -4,10 +4,12 @@ import type { DesktopLocale } from './locale.ts' import { createUpdateOverlay } from './update-overlay.ts' /** Channels available only to the isolated update-dialog document. */ -export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', respond: 'dsh-update-dialog:respond' } as const +export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const /** Text and choices supplied by the main process, never by product documents. */ export interface UpdateDialogView { + /** Identifies the displayed choices; a response from an older prompt is rejected. */ + readonly revision: number readonly locale: string readonly title: string readonly message: string @@ -26,17 +28,22 @@ export interface UpdateDialogOptions extends MessageBoxOptions { /** The document can select only a displayed response index. */ export interface UpdateDialogApi { - status(): Promise - respond(index: number): Promise + status(): Promise + respond(revision: number, index: number): Promise + subscribe(listener: (view: UpdateDialogView | null) => void): () => void } // main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/update-dialog.html' -/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */ +/** One fading backdrop with replaceable confirmation content; aborted checks and mandatory policy cancel ordinary prompts. */ export class DesktopUpdateDialog { private disposed = false - private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => void } | undefined + private revision = 0 + private window: BrowserWindow | undefined + private parent: BrowserWindow | undefined + private closing: ReturnType | undefined + private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { this.active?.window.focus() } @@ -46,9 +53,11 @@ export class DesktopUpdateDialog { * @param locale - Shell-owned copy. */ constructor(private readonly preload: string, private readonly locale: DesktopLocale) { - ipcMain.handle(UPDATE_DIALOG_IPC.status, event => this.owned(event).view) - ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => { - const active = this.owned(event) + ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null }) + ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => { + this.owned(event) + const active = this.active + if (active === undefined || revision !== active.view.revision) throw new Error('desktop update: stale dialog response') if (typeof index !== 'number' || !Number.isInteger(index) || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { throw new Error('desktop update: invalid dialog response') @@ -60,34 +69,45 @@ export class DesktopUpdateDialog { /** * @param parent - Window blocked by this confirmation. * @param options - Main-owned localized content, response choices, and optional cancellation signal. - * @returns A displayed response, or the cancel response when closed, replaced, aborted, or unable to load. + * @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently. */ show(parent: BrowserWindow, options: UpdateDialogOptions): Promise { - this.cancel() const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge] const cancelId = options.cancelId ?? buttons.length - 1 if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) { return Promise.resolve({ response: cancelId, checkboxChecked: false }) } - const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle) - const view: UpdateDialogView = { locale: this.locale.id, title: options.title ?? '', message: options.message, + if (this.parent !== parent) { this.cancel(); this.close() } + this.active?.finish(this.active.view.cancelId, true) + clearTimeout(this.closing) + this.closing = undefined + const existing = this.window + const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false) + this.window = window + this.parent = parent + const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message, detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose, technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails } return new Promise((resolve) => { const abort = (): void => { finish(cancelId) } - const finish = (response: number): void => { - if (this.active?.window !== window) return + const finish = (response: number, retain = false): void => { + if (this.active?.view !== view) return this.active = undefined options.signal?.removeEventListener('abort', abort) - if (!window.isDestroyed()) window.destroy() + if (!retain && !window.isDestroyed()) { + window.webContents.send(UPDATE_DIALOG_IPC.changed, null) + this.closing = setTimeout(() => { this.close() }, 150) + } resolve({ response, checkboxChecked: false }) } this.active = { window, view, finish } options.signal?.addEventListener('abort', abort, { once: true }) - window.once('closed', abort) + if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } + const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } } + window.once('closed', failed) window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() }) - window.webContents.once('render-process-gone', abort) - void window.loadURL(page).catch(abort) + window.webContents.once('render-process-gone', failed) + void window.loadURL(page).catch(failed) }) } @@ -99,16 +119,25 @@ export class DesktopUpdateDialog { if (this.disposed) return this.disposed = true this.cancel() + this.close() ipcMain.removeHandler(UPDATE_DIALOG_IPC.status) ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond) } - private owned(event: IpcMainInvokeEvent): NonNullable { - const active = this.active - if (active === undefined || event.sender !== active.window.webContents - || event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) { + private close(): void { + clearTimeout(this.closing) + this.closing = undefined + const window = this.window + this.window = undefined + this.parent = undefined + if (window !== undefined && !window.isDestroyed()) window.destroy() + } + + private owned(event: IpcMainInvokeEvent): void { + const window = this.window + if (window === undefined || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) { throw new Error('desktop update: rejected unowned dialog renderer') } - return active } } diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index 39bcde23db..dc829fe4f3 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -5,15 +5,27 @@ import { BrowserWindow } from 'electron' * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. * @param title - Localized window title. + * @param nativeModal - Use a native modal; false keeps ordinary confirmations out of macOS sheets. * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. */ -export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string): BrowserWindow { +export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { const window = new BrowserWindow({ - parent, modal: true, show: false, frame: false, transparent: true, + parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, hasShadow: false, title, webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) + // macOS native modals animate the entire viewport as a sheet. + const focus = (): void => { if (!window.isDestroyed()) window.focus() } + const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } + if (!nativeModal && process.platform === 'darwin') { + parent.on('focus', focus) + parent.webContents.on('before-input-event', blockInput) + window.once('closed', () => { + parent.off('focus', focus) + parent.webContents.off('before-input-event', blockInput) + }) + } const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } parent.on('move', follow) parent.on('resize', follow) diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index 3626bcfcad..e748f54db7 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -73,12 +73,14 @@ export async function qualifyUpdateDialogs(root, fixture) { const name = active ? 'update-active-tasks.png' : 'update-ready.png' await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) screenshots.push(name) + const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) }) if (active) { await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined") } else { window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) } const result = await pending + await closed assert.equal(window.isDestroyed(), true) assert.equal(result.phase, active ? 'installing' : 'ready') assert.equal(f.installations.length, active ? 1 : 0) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3df37cb4ae..1c92e81aa8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -289,7 +289,7 @@ beforeEach(() => { vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') - vi.stubGlobal('process', { ...process, platform: 'win32', resourcesPath: 'desktop-test-resources' }) + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) @@ -826,7 +826,10 @@ describe('desktop main startup', () => { harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => { checking.resolve(signal) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) }) - }).mockResolvedValueOnce({ response: 0 }) + }).mockImplementationOnce(() => { + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false) + return Promise.resolve({ response: 0 }) + }) const submenu = applicationMenuItems() const action = submenu.find(item => item.label === 'Check for Updates…') expect(action?.click).toBeTypeOf('function') diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 3c866c75c7..a163dd6e1a 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -10,11 +10,13 @@ const fixture = await vi.hoisted(async () => { class FakeWindow extends EventEmitter { destroyed = false readonly webContents = Object.assign(new EventEmitter(), { + send: vi.fn(), mainFrame: { url: 'dsh-app://shell/update-dialog.html' }, setWindowOpenHandler: vi.fn(), insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}), }) + readonly focus = vi.fn() readonly show = vi.fn() readonly setBounds = vi.fn() readonly loadURL = vi.fn(async () => {}) @@ -33,6 +35,7 @@ vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: { let dialogs: DesktopUpdateDialog | undefined afterEach(() => { + vi.useRealTimers() dialogs?.dispose() dialogs = undefined for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy() @@ -48,7 +51,9 @@ function setup() { }) const invoke = (channel: string, ...args: unknown[]) => { const window = fixture.windows.at(-1)! - return fixture.handlers.get(channel)!({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, ...args) + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number } + return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args)) } return { parent, show, invoke } } @@ -73,7 +78,7 @@ it('follows the parent geometry and removes listeners when closed or replaced', const f = setup() const first = f.show() const window = fixture.windows.at(-1)! - expect(window.options).toMatchObject({ frame: false, transparent: true, modal: true, width: 900, height: 650 }) + expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 }) window.emit('ready-to-show') expect(window.show).toHaveBeenCalledOnce() f.parent.emit('resize') @@ -85,7 +90,7 @@ it('follows the parent geometry and removes listeners when closed or replaced', expect((await next).response).toBe(1) expect(f.parent.listenerCount('resize')).toBe(0) expect(f.parent.listenerCount('move')).toBe(0) - expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(2) + expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(1) }) it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => { @@ -132,3 +137,57 @@ it('supplies localized disclosure copy without putting diagnostics in the ordina dialogs!.cancel() await pending }) + +it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => { + const f = setup() + const abort = new AbortController() + const first = f.show(abort.signal) + const window = fixture.windows.at(-1)! + const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number } + const next = f.show() + expect((await first).response).toBe(1) + expect(fixture.windows).toHaveLength(2) + expect(window.loadURL).toHaveBeenCalledOnce() + expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]]) + expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled() + abort.abort() + expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + }, old.revision, 0)).toThrow(/stale/) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect((await next).response).toBe(0) +}) + +it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => { + vi.useFakeTimers() + const f = setup() + const first = f.show() + const window = fixture.windows.at(-1)! + dialogs!.cancel() + await first + expect(window.isDestroyed()).toBe(false) + expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null) + const next = f.show() + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(false) + dialogs!.cancel() + await next + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(true) + expect(f.parent.listenerCount('focus')).toBe(0) + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) + +it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => { + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const event = { preventDefault: vi.fn() } + f.parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + f.parent.emit('focus') + expect(window.focus).toHaveBeenCalledTimes(2) + dialogs!.dispose() + await pending + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index e8b925cf2f..03528a86f5 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -25,12 +25,13 @@ function page(name: string) { it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyboard-accessible: %s', async (language) => { const p = page('update-dialog') const locale = resolveDesktopLocale(language) - const state: UpdateDialogView = { locale: locale.id, title: locale.messages.updateFailedTitle, + const state: UpdateDialogView = { revision: 1, locale: locale.id, title: locale.messages.updateFailedTitle, message: locale.messages.updateStopFailed, detail: '', buttons: [locale.messages.updateAcknowledge], cancelId: 0, closeLabel: locale.messages.updateClose, technicalDetailsLabel: locale.messages.updateTechnicalDetails, technicalDetails: '\nexit 0; shutdown acknowledged false' } const respond = vi.fn(async () => {}) - const api: UpdateDialogApi = { status: async () => state, respond } + let publish!: (view: UpdateDialogView | null) => void + const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } } Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api }) p.run() await expect.poll(() => p.element('dialog').hidden).toBe(false) @@ -54,7 +55,20 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb expect(disclosure.open).toBe(false) expect(respond).not.toHaveBeenCalled() p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })) - expect(respond).toHaveBeenCalledWith(0) + expect(respond).toHaveBeenCalledWith(1, 0) + const backdrop = p.document.body + const dialog = p.element('dialog') + publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] }) + expect(p.document.body).toBe(backdrop) + expect(p.element('dialog')).toBe(dialog) + expect(backdrop.classList.contains('visible')).toBe(true) + expect(p.element('actions').childElementCount).toBe(1) + expect(disclosure.open).toBe(false) + expect(p.document.activeElement).toBe(dialog) + publish(state) + expect(p.element('title').textContent).toBe(locale.messages.updateChecking) + publish(null) + expect(backdrop.classList.contains('visible')).toBe(false) }) it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => { From a666209a59645b75e97607e233c7b3c2c472438d Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 14:06:26 +0800 Subject: [PATCH 2/4] fix(desktop): fade mandatory update overlays in place --- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/mandatory-update.html | 2 +- apps/desktop/renderer/mandatory-update.js | 2 ++ apps/desktop/renderer/update-dialog.css | 9 ++++--- apps/desktop/renderer/update-dialog.html | 2 +- apps/desktop/src/mandatory-update-window.ts | 16 ++++++++++-- apps/desktop/src/update-overlay.ts | 4 +-- .../tests/mandatory-update-window.spec.ts | 24 +++++++++++++++++- .../tests/update-error-renderer.spec.ts | 5 ++++ apps/desktop/tests/update-overlay.spec.ts | 25 +++++++++++++++++++ 12 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 50d6cc47c4..f604997189 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 94abdbb01028639da51755a5084a063ac4cf7f84 -README.zh.md: 0a7f5a82169f2b8b2a74910d2c912a33a7ff4c8f +README.md: 2948fa988fb5dcc646430b5bade6d07de6b01056 +README.zh.md: f8e6469295f15addb36eff4b04ba8e687013d7dd diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 94abdbb010..2948fa988f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -295,7 +295,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 0a7f5a8216..f8e6469295 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -296,7 +296,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index c0c4a28513..673c5d0953 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -8,7 +8,7 @@ - +

diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index 807209a0cd..24e1f88041 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -7,6 +7,8 @@ const element = id => document.getElementById(id) const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match) function render(view) { + document.body.classList.toggle('visible', view.policy.blocking) + if (!view.policy.blocking) return const wasConfirming = current?.confirmation !== undefined current = view const { locale: { id, messages }, policy, update, confirmation, navigation } = view diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index 5ceffbb363..98183989a5 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -5,7 +5,7 @@ background: transparent; } * { box-sizing: border-box; } -body { opacity: 0; transition: opacity 150ms ease; margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } +body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } @@ -25,6 +25,7 @@ summary { cursor: pointer; color: #5b6472; } pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } button:disabled { opacity: 0.5; cursor: default; } [hidden] { display: none !important; } -body.visible { opacity: 1; } -@starting-style { body.visible { opacity: 0; } } -@media (prefers-reduced-motion: reduce) { body { transition: none; } } +body.update-dialog { opacity: 0; transition: opacity 150ms ease; } +body.update-dialog.visible { opacity: 1; } +@starting-style { body.update-dialog.visible { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } } diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html index 176a0dcf12..85a0f20abd 100644 --- a/apps/desktop/renderer/update-dialog.html +++ b/apps/desktop/renderer/update-dialog.html @@ -7,7 +7,7 @@ - +

diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index a3ca6bcf8f..17438ab15e 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -49,6 +49,7 @@ const page = 'dsh-app://shell/mandatory-update.html' /** A modal child blocks the product window without cancelling work in the Host. */ export class DesktopMandatoryUpdateWindow { private window: BrowserWindow | undefined + private closing: ReturnType | undefined private disposed = false private error: string | undefined private action: Promise | undefined @@ -142,11 +143,20 @@ export class DesktopMandatoryUpdateWindow { this.clearNavigation() this.restart = undefined this.deferred = false - this.window?.destroy() - this.window = undefined + if (this.window !== undefined && this.closing === undefined) { + const window = this.window + window.webContents.send(MANDATORY_IPC.state, this.view()) + this.closing = setTimeout(() => { + this.closing = undefined + if (this.window === window) this.window = undefined + window.destroy() + }, 150) + } this.error = undefined return } + clearTimeout(this.closing) + this.closing = undefined if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined if (this.window === undefined) { @@ -186,6 +196,8 @@ export class DesktopMandatoryUpdateWindow { /** Detach IPC and release the modal during application shutdown. */ dispose(): void { this.disposed = true + clearTimeout(this.closing) + this.closing = undefined this.finishConfirmation(false) this.attention.reset() this.clearNavigation() diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index dc829fe4f3..2ff0b8c0d4 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -5,7 +5,7 @@ import { BrowserWindow } from 'electron' * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. * @param title - Localized window title. - * @param nativeModal - Use a native modal; false keeps ordinary confirmations out of macOS sheets. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. */ export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { @@ -51,7 +51,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl /** A native Windows modal retains its own title bar while the product window remains blocked. */ export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, platform: NodeJS.Platform = process.platform): BrowserWindow { - if (platform !== 'win32') return createUpdateOverlay(parent, preload, title) + if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) const window = new BrowserWindow({ parent, modal: true, show: false, title, width: 640, height: 560, minWidth: 480, minHeight: 360, diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 4df1f34c69..84f9195122 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -24,7 +24,7 @@ function fakeWindow() { loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) } -afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear() }) +afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) function setup() { window = fakeWindow() let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } @@ -130,3 +130,25 @@ it('exits the application when the mandatory window is closed without clearing t expect(native.quit).toHaveBeenCalledOnce() expect(f.view().policy.blocking).toBe(true) }) + +it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => { + vi.useFakeTimers() + const f = setup() + f.policy({ blocking: false, checking: false }) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, + f.view()) + vi.advanceTimersByTime(100) + expect(window.destroy).not.toHaveBeenCalled() + f.policy({ blocking: true, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).not.toHaveBeenCalled() + expect(window.loadURL).toHaveBeenCalledOnce() + f.policy({ blocking: false, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).toHaveBeenCalledOnce() + expect(ui!.confirmationWindow).toBeUndefined() + f.policy({ blocking: true, checking: false }) + f.policy({ blocking: false, checking: false }) + ui!.dispose() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 03528a86f5..4f0103bb17 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -114,6 +114,11 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori expect(disclosure.hidden).toBe(true) expect(p.element('error').hidden).toBe(true) expect(p.document.getElementById('quit')).toBeNull() + expect(p.document.body.classList.contains('visible')).toBe(true) + publish({ ...initial, policy: { blocking: false, checking: false } }) + expect(p.document.body.classList.contains('visible')).toBe(false) + publish(initial) + expect(p.document.body.classList.contains('visible')).toBe(true) p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide')) expect(unsubscribe).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 712163265b..97bb5cc266 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -25,3 +25,28 @@ it('gives the Windows mandatory modal native move, resize, and maximize controls expect(window.show).toHaveBeenCalledOnce() expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() }) + +it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const parent = Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + native.create.mockReturnValue(window) + try { + createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + const event = { preventDefault: vi.fn() } + parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + window.emit('closed') + expect(parent.listenerCount('focus')).toBe(0) + expect(parent.webContents.listenerCount('before-input-event')).toBe(0) + } finally { platform.mockRestore() } +}) From 72d93cbcc2e5be32b15d18f10a1d87fdd0aea907 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 14:18:38 +0800 Subject: [PATCH 3/4] fix(desktop): reset scroll when replacing update prompts --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/update-dialog.js | 1 + apps/desktop/tests/update-error-renderer.spec.ts | 4 ++++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f604997189..0c0db6e288 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2948fa988fb5dcc646430b5bade6d07de6b01056 -README.zh.md: f8e6469295f15addb36eff4b04ba8e687013d7dd +README.md: 531c168c0d3fc3091a330f5350d0a85ac3009f08 +README.zh.md: 6a20f83623a54e8f87d05104280b97ddfb2a796a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2948fa988f..531c168c0d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -263,7 +263,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content while retaining the backdrop and background blur. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f8e6469295..6a20f83623 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -264,7 +264,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗仅替换卡片内容,保留蒙层与背景模糊。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index fab2abf05e..d03eadadf7 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -47,6 +47,7 @@ function render(state) { document.getElementById('actions').append(button) } document.querySelector('main').hidden = false + document.getElementById('dialog').scrollTop = 0 document.body.classList.add('visible') document.getElementById('dialog').focus() } diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 4f0103bb17..ad92f2df8f 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -58,14 +58,18 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb expect(respond).toHaveBeenCalledWith(1, 0) const backdrop = p.document.body const dialog = p.element('dialog') + dialog.scrollTop = 170 publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] }) expect(p.document.body).toBe(backdrop) expect(p.element('dialog')).toBe(dialog) + expect(dialog.scrollTop).toBe(0) expect(backdrop.classList.contains('visible')).toBe(true) expect(p.element('actions').childElementCount).toBe(1) expect(disclosure.open).toBe(false) expect(p.document.activeElement).toBe(dialog) + dialog.scrollTop = 80 publish(state) + expect(dialog.scrollTop).toBe(80) expect(p.element('title').textContent).toBe(locale.messages.updateChecking) publish(null) expect(backdrop.classList.contains('visible')).toBe(false) From 0c3eb28276928ab29eec2ec8f4ea2293ca777fbb Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 14:29:29 +0800 Subject: [PATCH 4/4] test(desktop): await mandatory overlay fade after policy clearance --- apps/desktop/tests/main-startup.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 1c92e81aa8..d68d076971 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -812,6 +812,8 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) await vi.advanceTimersByTimeAsync(20_000) + expect(modal.isDestroyed()).toBe(false) + await vi.advanceTimersByTimeAsync(150) expect(modal.isDestroyed()).toBe(true) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' })