fix(desktop): embed mandatory updates without native child windows

This commit is contained in:
winewill committed 2026-10-20 18:58:04 +08:00
1 parent 495977f9d7
commit c4f49704a1
20 files changed
+318 -384

No files matched your search

+4 -4
View File
@@ -57,14 +57,14 @@
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
/deepseek-harness-desktop/updates:
en: b5b4fb07aab16e16
zh: 1bf66216bdee2dae
en: 98f65a8aa5056623
zh: 8ff5d1e2880188b0
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: 54596bef0706d305
zh: 48737302dcf3c939
en: 4d32791befb86d50
zh: e7610cb426243a20
/deepseek-harness-desktop/updates/local-updater-qualification:
en: 68ec2f8bd5763ea6
zh: 23b932dfe8503602
+2 -2
View File
@@ -271,7 +271,7 @@ On Windows, the downloaded-update confirmation explains that the application clo
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. Windows caption controls remain above the view. Mandatory update windows retain their separate policy and presentation.
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. Windows caption controls remain above the view.
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 dark translucent backdrop without blurring the parent page.
@@ -330,7 +330,7 @@ When permitted, login is offered during user-initiated checks and the packaged a
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 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 isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. 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 uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner requests application quit through the normal task confirmation flow. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. 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.
+2 -2
View File
@@ -271,7 +271,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 标题栏控件位于视图上方。强制更新窗口保留独立的策略和呈现方式。
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 标题栏控件位于视图上方。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
@@ -330,7 +330,7 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口会通过常规任务确认流程请求退出应用。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
+1
View File
@@ -1084,6 +1084,7 @@ async function main(): Promise<void> {
window.on('close', (event) => {
if (quitting || shellInstallerOwnsQuit || sessionEnding) return
event.preventDefault()
if (process.platform !== 'win32' && isMandatory()) { app.quit(); return }
if (updateDialog.isOpen) { updateDialog.focus(); return }
const hide = (): void => {
if (!quitting && !shellInstallerOwnsQuit && !sessionEnding && !window.isDestroyed()) hideMainWindow(window)
+29 -15
View File
@@ -1,11 +1,11 @@
/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { type BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import type { DesktopLocale } from './locale.ts'
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import type { DesktopUpdateOverlays } from './update-overlay.ts'
import type { DesktopUpdateOverlay, DesktopUpdateOverlays } from './update-overlay.ts'
import { DesktopUpdateAttention } from './update-attention.ts'
/** A renderer action never carries a URL or authorizes a different version. */
@@ -47,13 +47,14 @@ export interface MandatoryUpdateWindowOptions {
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/mandatory-update.html'
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
/** Shell-owned update presentation inside the native owner; policy and installation approval remain main-owned. */
export class DesktopMandatoryUpdateWindow {
private readonly embedded = process.platform === 'win32'
private embeddedParent: BrowserWindow | undefined
private embeddedBlocking = false
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
private window: BrowserWindow | undefined
private window: DesktopUpdateOverlay | undefined
private surfaceParent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
private error: string | undefined
@@ -110,7 +111,7 @@ export class DesktopMandatoryUpdateWindow {
}
/** Active modal used as the owner of shell installation-confirmation dialogs. */
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.surfaceParent }
/**
* @param version - Updater-owned target, already downloaded and verified.
@@ -145,6 +146,13 @@ export class DesktopMandatoryUpdateWindow {
/** Publish current status, create the block immediately, or close it only after policy clearance. */
sync(): void {
if (this.disposed) return
if (!this.embedded && this.window !== undefined && this.surfaceParent !== this.options.parent()) {
this.finishConfirmation(false)
const previous = this.window
this.window = undefined
this.surfaceParent = undefined
previous.destroy()
}
if (this.embedded) {
const parent = this.options.parent()
if (parent !== this.embeddedParent) {
@@ -168,7 +176,7 @@ export class DesktopMandatoryUpdateWindow {
window.webContents.send(MANDATORY_IPC.state, this.view())
this.closing = setTimeout(() => {
this.closing = undefined
if (this.window === window) this.window = undefined
if (this.window === window) { this.window = undefined; this.surfaceParent = undefined }
window.destroy()
}, 150)
}
@@ -190,23 +198,29 @@ export class DesktopMandatoryUpdateWindow {
}
if (this.window === undefined) {
const parent = this.options.parent()
if (parent === undefined) return
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
if (parent === undefined || parent.isDestroyed()) return
const window = this.options.overlays.create(parent, this.options.preload)
this.window = window
window.setMenu(null)
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
window.on('closed', () => { if (this.window === window) this.window = undefined })
this.surfaceParent = parent
window.webContents.once('destroyed', () => {
if (this.window === window) {
this.window = undefined; this.surfaceParent = undefined
this.finishConfirmation(false)
if (!this.disposed) queueMicrotask(() => { this.sync() })
}
})
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
window.webContents.on('will-redirect', (event) => { event.preventDefault() })
window.webContents.on('render-process-gone', () => {
if (!this.disposed) void window.loadURL(page).catch(() => {
// A failed recovery keeps the parent blocked and leaves application exit available.
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
if (!window.isDestroyed()) parent.setTitle(this.options.locale.messages.mandatoryActionFailed)
})
})
void window.loadURL(page).catch(() => {
// The parent stays modal-blocked if its dedicated recovery document cannot load.
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
if (!window.isDestroyed()) parent.setTitle(this.options.locale.messages.mandatoryActionFailed)
})
}
this.window.webContents.send(MANDATORY_IPC.state, this.view())
@@ -219,8 +233,7 @@ export class DesktopMandatoryUpdateWindow {
if (parent?.isDestroyed()) return
if (parent?.isMinimized()) parent.restore()
parent?.show()
if (this.embedded) parent?.focus()
this.window?.show()
parent?.focus()
this.window?.focus()
}
@@ -240,6 +253,7 @@ export class DesktopMandatoryUpdateWindow {
}
this.window?.destroy()
this.window = undefined
this.surfaceParent = undefined
}
private view(): MandatoryUpdateView {
+8 -43
View File
@@ -1,7 +1,7 @@
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
import { ipcMain, WebContentsView, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
import type { DesktopLocale } from './locale.ts'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
import { DesktopUpdateOverlays, type DesktopUpdateOverlay } from './update-overlay.ts'
/** Channels available only to the isolated update-dialog document. */
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
@@ -40,9 +40,8 @@ const page = 'dsh-app://shell/update-dialog.html'
export class DesktopUpdateDialog {
private disposed = false
private revision = 0
private view: WebContentsView | undefined
private releaseParent: (() => void) | undefined
private readonly inputs = new WeakMap<BrowserWindow, { revision: number; blocked: boolean }>()
private view: DesktopUpdateOverlay | undefined
private readonly overlays = new DesktopUpdateOverlays()
private parent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
@@ -59,7 +58,7 @@ export class DesktopUpdateDialog {
* @returns Blocking state and a revision changed on attachment or release.
*/
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
return this.inputs.get(parent) ?? { revision: 0, blocked: false }
return this.overlays.input(parent)
}
/** Whether a shell prompt is awaiting a response. */
@@ -100,9 +99,7 @@ export class DesktopUpdateDialog {
clearTimeout(this.closing)
this.closing = undefined
const existing = this.view
const surface = existing ?? new WebContentsView({ webPreferences: {
preload: this.preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true,
} })
const surface = existing ?? this.overlays.create(parent, this.preload)
this.view = surface
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
@@ -124,41 +121,13 @@ export class DesktopUpdateDialog {
options.signal?.addEventListener('abort', abort, { once: true })
if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } }
const contents = parent.webContents
const resize = (): void => {
const { width, height } = parent.getContentBounds()
const top = process.platform === 'win32' ? WINDOWS_TITLEBAR_HEIGHT : 0
surface.setBounds({ x: 0, y: top, width, height: Math.max(0, height - top) })
}
const focus = (): void => {
if (!parent.isDestroyed() && parent.isFocused() && !surface.webContents.isDestroyed()) surface.webContents.focus()
}
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
const input = this.inputs.get(parent) ?? { revision: 0, blocked: false }
this.inputs.set(parent, input)
input.blocked = true; input.revision++
surface.setBackgroundColor('#00000000')
parent.contentView.addChildView(surface)
resize()
parent.on('resize', resize)
parent.on('focus', focus)
parent.once('closed', failed)
contents.prependListener('before-input-event', blockInput)
this.releaseParent = () => {
parent.off('resize', resize)
parent.off('focus', focus)
parent.off('closed', failed)
contents.off('before-input-event', blockInput)
if (!parent.isDestroyed()) parent.contentView.removeChildView(surface)
input.blocked = false; input.revision++
}
surface.webContents.once('destroyed', failed)
surface.webContents.once('render-process-gone', failed)
surface.webContents.once('preload-error', failed)
surface.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
surface.webContents.on('will-redirect', (event) => { event.preventDefault() })
surface.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
void surface.webContents.loadURL(page).then(focus, failed)
void surface.loadURL(page).catch(failed)
})
}
@@ -180,12 +149,8 @@ export class DesktopUpdateDialog {
this.closing = undefined
const surface = this.view
this.view = undefined
this.releaseParent?.()
this.releaseParent = undefined
const parent = this.parent
this.parent = undefined
if (surface !== undefined && !surface.webContents.isDestroyed()) surface.webContents.close({ waitForBeforeUnload: false })
if (parent !== undefined && !parent.isDestroyed() && parent.isFocused()) parent.webContents.focus()
surface?.destroy()
}
private owned(event: IpcMainInvokeEvent): void {
+71 -51
View File
@@ -1,63 +1,83 @@
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
import { BrowserWindow } from 'electron'
/** Isolated update documents share their owner's native window and input lifetime. */
import { WebContentsView, type BrowserWindow, type WebContents, type View } from 'electron'
import type { EventEmitter } from 'node:events'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
const unblockedInput = { revision: 0, blocked: false } as const
/** Main-owned document with no independent native window or activation state. */
export interface DesktopUpdateOverlay {
readonly webContents: WebContents
/** Focus the document only while its owner already has focus. */
focus(): void
/** Release the view, input interception, and owner listeners. */
destroy(): void
/** @returns Whether the document has been destroyed. */
isDestroyed(): boolean
/** @param url - Shell-owned document URL. @returns Completion of document loading. */
loadURL(url: string): Promise<void>
}
/** Tracks application-owned update overlays and their parent input state. */
type UpdateOwner = Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed' | 'isFocused'>
& Pick<EventEmitter, 'on' | 'once' | 'off'> & {
readonly webContents: Pick<EventEmitter, 'prependListener' | 'off'> & Pick<WebContents, 'focus'>
readonly contentView: Pick<View, 'addChildView' | 'removeChildView'>
}
/** Tracks isolated update views and each owner's keyboard-blocking revision. */
export class DesktopUpdateOverlays {
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
private readonly inputs = new WeakMap<object, { revision: number; active: number; readonly blocked: boolean }>()
/**
* @param parent - Product window whose input may belong to an update dialog.
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
*/
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
return this.inputStates.get(parent) ?? unblockedInput
/** @param parent - Product window. @returns Blocking state and attachment/release revision. */
input(parent: object): { readonly revision: number; readonly blocked: boolean } {
const state = this.inputs.get(parent)
return { revision: state?.revision ?? 0, blocked: state?.blocked ?? false }
}
/**
* @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 bounds and visibility after loading and releases its listeners on close.
* @param parent - Native owner; its caption remains operable.
* @param preload - Isolated shell preload.
* @returns Document attached inside the owner, without showing or activating a native window.
*/
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
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 },
})
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
this.inputStates.set(parent, inputState)
inputState.active++; inputState.revision++
window.once('closed', () => { inputState.active--; inputState.revision++ })
// 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)
// Shell dialogs block input before product shortcut listeners can dispatch it.
parent.webContents.prependListener('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
})
create(parent: UpdateOwner, preload: string): DesktopUpdateOverlay {
const view = new WebContentsView({ webPreferences: {
preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true,
} })
const ownerContents = parent.webContents
const contents = view.webContents
const input = this.inputs.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
this.inputs.set(parent, input)
input.active++; input.revision++
const resize = (): void => {
const { width, height } = parent.getContentBounds()
const top = process.platform === 'win32' ? WINDOWS_TITLEBAR_HEIGHT : 0
view.setBounds({ x: 0, y: top, width, height: Math.max(0, height - top) })
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
let ready = false
const show = (): void => {
if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show()
const focus = (): void => {
if (!parent.isDestroyed() && parent.isFocused() && !contents.isDestroyed()) contents.focus()
}
parent.on('show', show)
window.once('closed', () => { parent.off('show', show) })
window.once('ready-to-show', () => { ready = true; show() })
window.setMenu(null)
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
const block = (event: Electron.Event): void => { event.preventDefault(); focus() }
const destroy = (): void => { if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) }
const release = (): void => {
parent.off('resize', resize)
parent.off('focus', focus)
parent.off('closed', destroy)
ownerContents.off('before-input-event', block)
input.active--; input.revision++
if (!parent.isDestroyed()) {
parent.contentView.removeChildView(view)
if (parent.isFocused()) ownerContents.focus()
}
}
view.setBackgroundColor('#00000000')
parent.contentView.addChildView(view)
resize()
parent.on('resize', resize)
parent.on('focus', focus)
parent.once('closed', destroy)
ownerContents.prependListener('before-input-event', block)
contents.once('destroyed', release)
contents.on('did-finish-load', focus)
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
return { webContents: contents, focus, destroy, isDestroyed: () => contents.isDestroyed(),
loadURL: url => contents.loadURL(url) }
}
}
+2 -2
View File
@@ -12,8 +12,8 @@
en: a5edee063916cd27
zh: 7a5286350ff09e58
/desktop-local-update-verification/native-overlay-visibility:
en: cff2c259a195bb57
zh: fa03ff3fdc32a346
en: 6f4de4503748f3e2
zh: 648370375f16e463
/desktop-local-update-verification/manual-walkthrough:
en: c4b4ee8763d8464a
zh: ee2159bc08966592
+1 -1
View File
@@ -26,7 +26,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
```
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, unfiltered parent content, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow.
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It checks visible and hidden owners, absence of native child windows, retention of a separate control window’s focus after owner visibility notifications, and view/input cleanup including owner destruction. It uses no network, product login, or dsh Host. It qualifies the in-window carrier, not the full first-login or installation flow.
<a id="verification-interactive"></a>
+1 -1
View File
@@ -26,7 +26,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
```
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、父页面不受模糊影响及监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它检查可见和隐藏的父窗口、不创建原生子窗口、父窗口可见性通知后独立对照窗口仍保留焦点,以及销毁父窗口时的视图和输入清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖窗内承载方式,不覆盖完整的首次登录或安装流程。
<a id="verification-interactive"></a>
@@ -1,15 +1,5 @@
[
{
"initiallyVisible": true,
"loaded": { "parent": true, "overlay": true, "filter": "none" },
"restored": { "parent": true, "overlay": true, "filter": "none" },
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
},
{
"initiallyVisible": false,
"loaded": { "parent": false, "overlay": false, "filter": "none" },
"restored": { "parent": true, "overlay": true, "filter": "none" },
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
},
{ "initiallyVisible": true, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
{ "initiallyVisible": false, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
{ "parentDestroyed": true, "overlayDestroyed": true }
]
+10 -7
View File
@@ -17,6 +17,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../
import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js'
import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
import { qualifyUpdateDialogs } from './update-dialogs.mjs'
const root = process.env.DSH_LOCAL_UPDATE_TEST_ROOT
@@ -244,6 +245,7 @@ async function main() {
() => { modal?.sync() }, undefined,
() => ({ version: '1.0.0', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }))
modal = new DesktopMandatoryUpdateWindow({
overlays: new DesktopUpdateOverlays(),
preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)),
locale: resolveDesktopLocale('zh-CN'), allowedPageOrigins: config.allowedPageOrigins,
parent: () => parent, policy: () => policy.state, update: () => f.coordinator.state,
@@ -268,11 +270,12 @@ async function main() {
await policy.check('launch')
const window = modal.confirmationWindow
assert.ok(window)
const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents
const contents = process.platform === 'win32' ? await mandatoryFrame(parent)
: parent.contentView.children.at(-1).webContents
assert.equal(window.isModal(), false)
assert.equal(parent.isEnabled(), true)
window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
contents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
contents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
async function until(expression) {
try { return await contents.executeJavaScript(`new Promise((resolve, reject) => {
const observer = new MutationObserver(check);
@@ -325,7 +328,7 @@ async function main() {
await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled")
assert.equal(f.installations.length, 0)
let capture
try { capture = await window.webContents.capturePage() }
try { capture = await contents.capturePage() }
catch (error) { screenshot = { captured: false, reason: String(error) } }
if (capture !== undefined) {
await writeFile(join(root, 'mandatory-update.png'), capture.toPNG())
@@ -348,15 +351,15 @@ async function main() {
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
}
const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png'
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
await writeFile(join(root, name), (await contents.capturePage()).toPNG())
dialogScreenshots.push(name)
}
assert.equal(policy.state.blocking, true)
assert.equal(f.installations.length, 0)
server.policy('clear')
const closed = process.platform === 'win32' ? undefined : once(window, 'closed')
const closed = process.platform === 'win32' ? undefined : once(contents, 'destroyed')
await policy.check('manual', true)
if (closed) { await closed; assert.equal(window.isDestroyed(), true) }
if (closed) { await closed; assert.equal(contents.isDestroyed(), true) }
else {
const deadline = Date.now() + 10000
while (!contents.detached && Date.now() < deadline) await delay(20)
+37 -72
View File
@@ -1,4 +1,4 @@
/** Native overlay visibility qualification without a Host, network, or user profile. */
/** Native-owner visibility and focus qualification for isolated in-window update views. */
import assert from 'node:assert/strict'
import { once } from 'node:events'
import { mkdirSync, mkdtempSync } from 'node:fs'
@@ -7,88 +7,53 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, BrowserWindow } from 'electron'
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
mkdirSync(directory, { recursive: true })
const root = mkdtempSync(join(directory, 'update-overlay-'))
mkdirSync(join(root, 'profile'))
app.setPath('userData', join(root, 'profile'))
app.on('window-all-closed', () => {})
async function main() {
await app.whenReady()
const cases = []
const windows = []
const closeWindows = () => {
for (const window of [...windows].reverse()) if (!window.isDestroyed()) window.destroy()
}
let phase = 'application ready'
const deadline = setTimeout(() => {
console.error(`Update overlay qualification exceeded 30 seconds: ${phase}`)
closeWindows()
app.exit(1)
}, 30_000)
async function qualify(initiallyVisible, closeParentFirst = false) {
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
windows.push(parent)
phase = `parent load (${initiallyVisible})`
await parent.loadURL('data:text/html,<h1>Update overlay visibility</h1><input aria-label="Keyboard probe">')
const initialShowListeners = parent.listenerCount('show')
const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Overlay qualification', false)
windows.push(overlay)
const ready = once(overlay, 'ready-to-show')
const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined
phase = `overlay load (${initiallyVisible})`
await overlay.loadURL('data:text/html,<h1>Update confirmation</h1>')
phase = `overlay ready (${initiallyVisible})`
await ready
await initiallyShown
const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter')
const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() }
if (initiallyVisible) {
const hidden = once(parent, 'hide')
parent.hide()
await hidden
}
const shown = once(parent, 'show')
phase = `overlay restored (${initiallyVisible})`
parent.show()
await shown
const restoredFilter = await filter()
const restored = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: restoredFilter }
phase = `overlay release (${initiallyVisible})`
if (closeParentFirst) {
parent.destroy()
if (!overlay.isDestroyed()) overlay.destroy()
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
return
}
overlay.destroy()
const released = { filter: await filter(), showListenersRestored: parent.listenerCount('show') === initialShowListeners,
keyboardListeners: parent.webContents.listenerCount('before-input-event') }
parent.hide()
parent.show()
cases.push({ initiallyVisible, loaded, restored, released })
parent.destroy()
}
const deadline = setTimeout(() => { console.error('Overlay qualification deadline exceeded'); app.exit(1) }, 30000)
try {
await app.whenReady()
await qualify(true)
await qualify(false)
await qualify(true, true)
await writeFile(join(root, 'result.json'), `${JSON.stringify(cases, null, 2)}\n`)
const expected = JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8'))
assert.deepEqual(cases, expected)
console.log(`Update overlay qualification passed: ${root}`)
} catch (error) {
console.error(error)
process.exitCode = 1
for (const initiallyVisible of [true, false]) {
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
windows.push(parent)
await parent.loadURL('data:text/html,<h1>Update owner</h1>')
const overlays = new DesktopUpdateOverlays()
const overlay = overlays.create(parent, undefined)
await overlay.loadURL('data:text/html,<h1>Isolated update</h1>')
assert.equal(parent.getChildWindows().length, 0)
assert.equal(parent.contentView.children.length, 1)
const control = new BrowserWindow({ show: true, width: 300, height: 200 })
windows.push(control)
await control.loadURL('data:text/html,<h1>Focus control</h1>')
const focused = control.isFocused() ? undefined : once(control, 'focus')
control.focus(); await focused
parent.emit('show')
assert.equal(control.isFocused(), true)
assert.equal(parent.isFocused(), false)
const destroyed = once(overlay.webContents, 'destroyed')
overlay.destroy(); await destroyed
cases.push({ initiallyVisible, childWindows: parent.getChildWindows().length, controlKeepsFocus: control.isFocused(),
viewsReleased: parent.contentView.children.length === 0, input: overlays.input(parent) })
parent.destroy(); control.destroy()
}
const parent = new BrowserWindow({ show: false })
windows.push(parent)
const overlay = new DesktopUpdateOverlays().create(parent, undefined)
const destroyed = once(overlay.webContents, 'destroyed')
parent.destroy(); await destroyed
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
assert.deepEqual(cases, JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8')))
await writeFile(join(root, 'result.json'), JSON.stringify(cases, null, 2) + '\n')
console.log(`Update view qualification passed: ${root}`)
} finally {
clearTimeout(deadline)
closeWindows()
app.exit(process.exitCode ?? 0)
for (const window of windows) if (!window.isDestroyed()) window.destroy()
}
}
void main().catch(error => { console.error(error); app.exit(1) })
void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
+5
View File
@@ -100,6 +100,11 @@ async function windowAt(url) {
if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
return mandatoryFrameDriver(await windowAt('dsh-app://app/'))
}
if (process.platform !== 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
let contents
await waitFor(() => { contents = webContents.getAllWebContents().find(candidate => candidate.getURL() === url); return contents !== undefined }, 'mandatory view document')
return { webContents: contents, isDestroyed: () => contents.isDestroyed() }
}
const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
if (existing) return existing
return new Promise((resolve, reject) => {
+10 -6
View File
@@ -18,17 +18,21 @@ const overlays = await vi.hoisted(async () => {
const { EventEmitter } = await import('node:events')
class Window extends EventEmitter {
private destroyed = false
readonly webContents = { setWindowOpenHandler: vi.fn() }
readonly webContents = Object.assign(new EventEmitter(), { setWindowOpenHandler: vi.fn(),
focus: vi.fn(), isDestroyed: () => this.destroyed, loadURL: vi.fn(async () => {}),
close: () => { this.destroyed = true; this.webContents.emit('destroyed') },
})
readonly focus = vi.fn()
readonly show = vi.fn()
readonly setBounds = vi.fn()
readonly setBackgroundColor = vi.fn()
setMenu() {}
isDestroyed() { return this.destroyed }
destroy() { this.destroyed = true; this.emit('closed') }
destroy() { this.destroyed = true; this.webContents.emit('destroyed') }
}
return { Window }
})
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, WebContentsView: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
}) } }))
@@ -89,7 +93,7 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
let current: WindowFixture | undefined = window
const updateMenu = vi.fn()
const updateOverlays = new DesktopUpdateOverlays()
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow))
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window))
keyboard.attach(current)
onTestFinished(() => { keyboard.dispose() })
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
@@ -108,12 +112,12 @@ function updateOverlayFixture(f: Awaited<ReturnType<typeof fixture>>) {
onTestFinished(() => { platform.mockRestore() })
const parent: WindowFixture & Pick<BrowserWindow, 'getContentBounds'> = Object.assign(f.window, {
getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }),
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
})
Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) })
return () => {
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false)
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload')
onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() })
expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled()
return overlay
}
}
+35 -3
View File
@@ -69,6 +69,7 @@ const harness = await vi.hoisted(async () => {
const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() })
class FakeWindow extends EventEmitter {
destroyed = false
readonly contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }
readonly urls: string[] = []
readonly webContents = Object.assign(new EventEmitter(), {
id: 42,
@@ -129,6 +130,18 @@ const harness = await vi.hoisted(async () => {
if (event.preventDefault.mock.calls.length === 0) this.destroy()
}
}
const views: FakeView[] = []
class FakeView extends FakeWindow {
constructor() {
super({ show: false })
windows.pop()
views.push(this)
Object.assign(this.webContents, {
loadURL: (url: string) => this.loadURL(url),
close: () => { this.destroy(); this.webContents.emit('destroyed') },
})
}
}
class FakeHost {
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
readonly inspectQuit = vi.fn(async () => ({ activeTasks: false, scheduledTasks: false }))
@@ -191,7 +204,7 @@ const harness = await vi.hoisted(async () => {
const shellDialog = { isOpen: false, focus: vi.fn() }
return {
failWindow(error: Error) { windowFailure = error },
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics,
trays, FakeTray, backgroundNotice, shellDialog,
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
platformDispose,
@@ -238,7 +251,8 @@ const harness = await vi.hoisted(async () => {
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
reset() {
accountListener = undefined
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
windows.length = 0
views.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
trays.length = 0
backgroundNotice.markerPath = undefined
shellDialog.isOpen = false
@@ -277,6 +291,7 @@ vi.mock('../src/policy-test-auth.ts', () => ({ DesktopPolicyTestAuth: class {
vi.mock('electron', () => ({
app: harness.app,
BrowserWindow: harness.FakeWindow,
WebContentsView: harness.FakeView,
dialog: harness.dialog,
shell: { openExternal: harness.openExternal },
nativeTheme: harness.nativeTheme,
@@ -1281,6 +1296,23 @@ describe('desktop main startup', () => {
expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1)
})
it('routes closing a macOS owner with a mandatory view through application quit', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
const host = await readyWorkspace()
await harness.policyBlocked.promise
harness.windows[0]!.close()
expect(harness.app.quit).toHaveBeenCalled()
expect(harness.backgroundNotice.close).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
expect(host.inspectQuit).toHaveBeenCalledOnce()
host.exited.resolve()
await harness.quitCompleted.promise
})
it('keeps the workspace visible while acknowledgement is pending and ignores a destroyed window', async () => {
await readyWorkspace()
const window = harness.windows[0]!
@@ -1798,7 +1830,7 @@ describe('desktop main startup', () => {
const host = await readyForUpdate()
if (mandatory) await harness.policyBlocked.promise
const window = harness.windows[0]!
const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
const dialogOwner = mandatory ? harness.views.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
@@ -140,15 +140,6 @@ it('rejects same-URL child frames and removes quit from renderer privileges', ()
expect(() => f.action('quit')).toThrow(/invalid action/)
})
it('exits the application when the mandatory window is closed without clearing the policy', () => {
const f = setup()
const event = { preventDefault: vi.fn() }
window.emit('close', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
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()
@@ -212,3 +203,23 @@ it.each(['darwin', 'win32'] as const)('includes the installation wait notice onl
? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
: '更新期间应用将暂时关闭,完成后会自动打开。')
})
it('updates the same macOS view in the background without showing or focusing the owner', () => {
const f = setup()
f.update({ phase: 'downloading', version: '1.0.1-nightly.1', percent: 35 })
f.update({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'download' })
expect(window.show).not.toHaveBeenCalled()
expect(window.focus).not.toHaveBeenCalled()
expect(window.loadURL).toHaveBeenCalledOnce()
expect(ui!.confirmationWindow).toBe(window)
expect(f.view().policy.blocking).toBe(true)
})
it('cancels outstanding approval when the mandatory view is destroyed without clearing policy', async () => {
const f = setup()
const approval = ui!.confirm('1.0.1-nightly.1', true)
window.webContents.emit('destroyed')
expect(await approval).toBe(false)
expect(f.view().policy.blocking).toBe(true)
})
+1 -1
View File
@@ -25,7 +25,7 @@ const fixture = await vi.hoisted(async () => {
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
setWindowOpenHandler: vi.fn<(handler: () => { action: 'deny' }) => void>(),
isDestroyed: () => this.destroyed,
loadURL: vi.fn(async () => { if (load.failure) throw new Error('load failed') }),
loadURL: vi.fn(async () => { if (load.failure) throw new Error('load failed'); this.webContents.emit('did-finish-load') }),
close: vi.fn(() => { this.destroyed = true; this.webContents.emit('destroyed') }),
})
readonly setBackgroundColor = vi.fn()
+76 -153
View File
@@ -1,160 +1,83 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
import { afterEach, expect, it, vi } from 'vitest'
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
const native = vi.hoisted(() => ({ create: vi.fn<(options: object) => object>() }))
vi.mock('electron', () => ({ WebContentsView: function (options: object) { return native.create(options) } }))
afterEach(() => { vi.restoreAllMocks() })
function visibilityFixture(visible = true) {
const visibility = { visible }
const parent: ParentFixture & Pick<BrowserWindow, 'isVisible'> = 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: (): boolean => false,
isVisible: () => visibility.visible,
function fixture() {
let destroyed = false
const parent = Object.assign(new EventEmitter(), {
getContentBounds: () => ({ x: 50, y: 40, width: 900, height: 650 }), isDestroyed: () => destroyed,
isFocused: vi.fn(() => false), focus: vi.fn(), show: vi.fn(),
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
webContents: Object.assign(new EventEmitter(), { focus: vi.fn() }),
destroy() { destroyed = true; parent.emit('closed') },
})
const window = Object.assign(new EventEmitter(), {
destroyed: false,
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed,
})
native.create.mockReturnValue(window)
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
return { parent, window, visibility }
}
it('restores a ready overlay each time its parent is shown and releases visibility ownership on close', async () => {
const { parent, window, visibility } = visibilityFixture()
window.emit('ready-to-show')
expect(window.show).toHaveBeenCalledOnce()
for (let index = 0; index < 2; index++) {
visibility.visible = false
parent.emit('hide')
visibility.visible = true
parent.emit('show')
await Promise.resolve()
}
expect(window.show).toHaveBeenCalledTimes(3)
await Promise.resolve()
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
window.destroyed = true
window.emit('closed')
expect(parent.listenerCount('show')).toBe(0)
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
parent.emit('show')
expect(window.show).toHaveBeenCalledTimes(3)
})
it('waits for both a visible parent and a ready document, in either order', async () => {
for (const readyFirst of [true, false]) {
const { parent, window, visibility } = visibilityFixture(false)
if (readyFirst) window.emit('ready-to-show')
else { visibility.visible = true; parent.emit('show') }
expect(window.show).not.toHaveBeenCalled()
if (readyFirst) { visibility.visible = true; parent.emit('show') }
else window.emit('ready-to-show')
await Promise.resolve()
expect(window.show).toHaveBeenCalledOnce()
window.destroyed = true
window.emit('closed')
}
})
it('does not show or change parent styles when closed before its document is ready', async () => {
const { parent, window, visibility } = visibilityFixture(false)
window.destroyed = true
window.emit('closed')
window.emit('ready-to-show')
visibility.visible = true
parent.emit('show')
await Promise.resolve()
expect(window.show).not.toHaveBeenCalled()
expect(parent.listenerCount('show')).toBe(0)
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
})
it('releases a macOS overlay after its parent has already been destroyed', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const { parent, window } = visibilityFixture()
await Promise.resolve()
vi.spyOn(parent, 'isDestroyed').mockReturnValue(true)
Object.defineProperty(parent, 'webContents', { get() { throw new Error('Object has been destroyed') } })
window.destroyed = true
expect(() => window.emit('closed')).not.toThrow()
for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0)
})
type ParentFixture = EventEmitter & Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed'> & {
webContents: EventEmitter & Pick<WebContents, 'insertCSS' | 'removeInsertedCSS'>
}
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: ParentFixture = 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 {
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
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() }
})
it('blocks each parent until its last owned overlay closes and invalidates each transition', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
onTestFinished(() => { platform.mockRestore() })
const parent = (): ParentFixture => 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 firstParent = parent() as BrowserWindow
const secondParent = parent() as BrowserWindow
const overlays = new DesktopUpdateOverlays()
const isolated = new DesktopUpdateOverlays()
native.create.mockImplementation(() => {
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
const views: ReturnType<typeof view>[] = []
function view() {
let closed = false
const webContents = Object.assign(new EventEmitter(), { focus: vi.fn(), isDestroyed: () => closed,
loadURL: vi.fn(async () => {}), setWindowOpenHandler: vi.fn(),
close: vi.fn(() => { if (!closed) { closed = true; webContents.emit('destroyed') } }),
})
onTestFinished(() => { window.emit('closed') })
return window
})
expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false })
const first = overlays.create(firstParent, 'owned', 'Update required', false)
const second = overlays.create(firstParent, 'owned', 'Confirm installation', false)
const other = overlays.create(secondParent, 'owned', 'Update required', false)
expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true })
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false })
first.emit('closed')
expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true })
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1)
second.emit('closed')
expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false })
expect(firstParent.listenerCount('move')).toBe(0)
expect(firstParent.listenerCount('resize')).toBe(0)
expect(firstParent.listenerCount('focus')).toBe(0)
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0)
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
other.emit('closed')
expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false })
return { get webContents() { if (closed) throw new Error('Destroyed native view'); return webContents },
setBounds: vi.fn(), setBackgroundColor: vi.fn() }
}
native.create.mockImplementation(() => { const next = view(); views.push(next); return next })
const overlays = new DesktopUpdateOverlays()
const owner = parent
return { parent, owner, views, overlays, create: () => overlays.create(owner, 'isolated-preload') }
}
it('never shows or activates its native owner on visibility or background loading', () => {
const f = fixture(); const overlay = f.create()
expect(f.parent.contentView.addChildView).toHaveBeenCalledWith(f.views[0])
f.parent.emit('show'); f.parent.emit('hide'); f.parent.emit('show')
overlay.webContents.emit('did-finish-load')
expect(f.parent.show).not.toHaveBeenCalled()
expect(f.parent.focus).not.toHaveBeenCalled()
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
expect(f.parent.listenerCount('show')).toBe(0)
overlay.destroy()
})
it.each(['darwin', 'win32'] as const)('fits inside the owner with native caption clearance on %s', (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
const f = fixture(); const overlay = f.create(); const top = platform === 'win32' ? 40 : 0
expect(f.views[0]!.setBounds).toHaveBeenCalledWith({ x: 0, y: top, width: 900, height: 650 - top })
f.parent.emit('resize'); expect(f.views[0]!.setBounds).toHaveBeenCalledTimes(2)
overlay.destroy()
expect(f.parent.listenerCount('resize')).toBe(0)
})
it('blocks keyboard input and redirects focus only while the owner is focused', () => {
const f = fixture(); const overlay = f.create(); const event = { preventDefault: vi.fn() }
f.parent.webContents.emit('before-input-event', event)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
f.parent.isFocused.mockReturnValue(true); f.parent.emit('focus')
expect(f.views[0]!.webContents.focus).toHaveBeenCalledOnce()
overlay.destroy()
expect(f.parent.webContents.focus).toHaveBeenCalledOnce()
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
it('keeps input blocked until every view is released and changes the shortcut revision', () => {
const f = fixture(); const first = f.create(); const second = f.create()
expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 2 })
first.destroy(); expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 3 })
first.destroy(); second.destroy()
expect(f.overlays.input(f.owner)).toEqual({ blocked: false, revision: 4 })
expect(new DesktopUpdateOverlays().input(f.owner)).toEqual({ blocked: false, revision: 0 })
})
it('closes the view after owner destruction without accessing destroyed native contents', () => {
const f = fixture(); const overlay = f.create()
Object.defineProperty(f.parent, 'webContents', { get() { throw new Error('Destroyed owner') } })
expect(() => { f.parent.destroy() }).not.toThrow()
expect(overlay.isDestroyed()).toBe(true)
expect(f.parent.listenerCount('focus')).toBe(0)
expect(f.parent.listenerCount('closed')).toBe(0)
expect(f.parent.contentView.removeChildView).not.toHaveBeenCalled()
})
+1
View File
@@ -21,6 +21,7 @@
"apps/desktop/src/browser-guests.ts",
"apps/desktop/src/ipc.ts",
"apps/desktop/src/update-overlay.ts",
"apps/desktop/src/windows-layout.ts",
"apps/desktop/tests/**/*.client.spec.tsx",
// Source-subpath test imports can arrive through workspace links whose
// realpath semantics vary by host. Load package CSS declarations directly.