Merge pull request #4523 from deepseek-harness/fix/modal-backdrop-transition
fix(desktop): keep update dialog backdrop stationary across prompts
This commit is contained in:
commit
3bd8cfdf20
19 files changed
+306
-83
No files matched your search
@@ -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: 531c168c0d3fc3091a330f5350d0a85ac3009f08
|
||||
README.zh.md: 6a20f83623a54e8f87d05104280b97ddfb2a796a
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
@@ -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 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
<link rel="stylesheet" href="mandatory-update.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="update-dialog">
|
||||
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
|
||||
<header><h1 id="title"></h1></header>
|
||||
<p id="detail"></p>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { 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,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.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; } }
|
||||
@@ -7,7 +7,7 @@
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="update-dialog">
|
||||
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
|
||||
<p id="detail"></p>
|
||||
|
||||
@@ -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,11 @@ void api.status().then(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()
|
||||
})
|
||||
}
|
||||
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) })
|
||||
+39
-32
@@ -474,43 +474,50 @@ async function main(): Promise<void> {
|
||||
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<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)
|
||||
} 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 }))
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined
|
||||
private disposed = false
|
||||
private error: string | undefined
|
||||
private action: Promise<void> | 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()
|
||||
|
||||
@@ -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<UpdateDialogView>,
|
||||
respond: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise<void>,
|
||||
status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise<UpdateDialogView | null>,
|
||||
respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise<void>,
|
||||
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)
|
||||
@@ -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<UpdateDialogView>
|
||||
respond(index: number): Promise<void>
|
||||
status(): Promise<UpdateDialogView | null>
|
||||
respond(revision: number, index: number): Promise<void>
|
||||
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<typeof setTimeout> | 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<MessageBoxReturnValue> {
|
||||
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<DesktopUpdateDialog['active']> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 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): 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)
|
||||
@@ -39,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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' })
|
||||
@@ -826,7 +828,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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: '<img src=x onerror="window.compromised=true">\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,24 @@ 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')
|
||||
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)
|
||||
})
|
||||
|
||||
it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => {
|
||||
@@ -100,6 +118,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()
|
||||
})
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
Reference in new issue
Block a user