fix(desktop): isolate mandatory update actions and handle main-window lifecycle
This commit is contained in:
1 parent
12f0608cad
commit
69ac149090
19 files changed
+246
-65
No files matched your search
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md
|
||||
2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237
|
||||
2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Windows embedded mandatory update
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit.
|
||||
|
||||
## Decision
|
||||
|
||||
Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay.
|
||||
|
||||
The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem.
|
||||
|
||||
**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document.
|
||||
|
||||
## Consequences
|
||||
|
||||
Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Windows 内嵌强更弹窗
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。
|
||||
|
||||
## 决策
|
||||
|
||||
Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。
|
||||
|
||||
隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。
|
||||
|
||||
**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。
|
||||
|
||||
## 后果
|
||||
|
||||
顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。
|
||||
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md
|
||||
2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f
|
||||
2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226
|
||||
2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c
|
||||
2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6
|
||||
@@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/
|
||||
|
||||
A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
|
||||
|
||||
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
|
||||
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
|
||||
|
||||
A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available.
|
||||
On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md).
|
||||
|
||||
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval.
|
||||
The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval.
|
||||
|
||||
The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target.
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ Status: implemented
|
||||
|
||||
协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
|
||||
|
||||
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
|
||||
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
|
||||
|
||||
壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。
|
||||
macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。
|
||||
|
||||
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。
|
||||
模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。
|
||||
|
||||
恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。
|
||||
|
||||
|
||||
@@ -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: e0c855b5e674fbc445bf7f9362fbd9e8810182f9
|
||||
README.zh.md: 52c4db17b125462518235ab580ddcf7a9db8b481
|
||||
README.md: 3e28e562a4fdc8b77ddd57a82e4764f8f1a3f6cb
|
||||
README.zh.md: 774115309cfc2ea5c2c581c394202180ec0d6fed
|
||||
@@ -320,7 +320,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 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. 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 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
|
||||
|
||||
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** The embedded shell page delegates actions only to its owning application preload. */
|
||||
if (window.parent !== window && window.dshMandatoryUpdate === undefined) {
|
||||
let state
|
||||
let port
|
||||
let sequence = 0
|
||||
const listeners = new Set()
|
||||
const requests = new Map()
|
||||
const initial = Promise.withResolvers()
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window.parent || event.origin !== 'dsh-app://app') return
|
||||
const receive = event => {
|
||||
const message = event.data
|
||||
if (message?.type === 'dsh-mandatory-state' && message.state) {
|
||||
state = message.state
|
||||
@@ -20,7 +20,14 @@ if (window.parent !== window && window.dshMandatoryUpdate === undefined) {
|
||||
if (message.ok) request.resolve()
|
||||
else request.reject(new Error('Update action failed'))
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', event => {
|
||||
if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app'
|
||||
|| event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return
|
||||
port = event.ports[0]
|
||||
port.onmessage = receive
|
||||
})
|
||||
window.addEventListener('pagehide', () => { port?.close() }, { once: true })
|
||||
window.dshMandatoryUpdate = {
|
||||
status: () => state ? Promise.resolve(state) : initial.promise,
|
||||
subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) },
|
||||
@@ -28,9 +35,8 @@ if (window.parent !== window && window.dshMandatoryUpdate === undefined) {
|
||||
const id = ++sequence
|
||||
const request = Promise.withResolvers()
|
||||
requests.set(id, request)
|
||||
window.parent.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision }, 'dsh-app://app')
|
||||
port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision })
|
||||
return request.promise
|
||||
},
|
||||
}
|
||||
window.parent.postMessage({ type: 'dsh-mandatory-ready' }, 'dsh-app://app')
|
||||
}
|
||||
@@ -410,7 +410,7 @@ async function main(): Promise<void> {
|
||||
protocol.handle(SCHEME, (request) => {
|
||||
const url = new URL(request.url)
|
||||
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
|
||||
// Removing this route leaves those modal windows empty while their parent remains blocked.
|
||||
// Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked.
|
||||
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
|
||||
if (url.hostname === 'app') {
|
||||
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import type { DesktopUpdateState } from './ipc.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 { createUpdateOverlay } from './update-overlay.ts'
|
||||
@@ -49,8 +49,9 @@ const page = 'dsh-app://shell/mandatory-update.html'
|
||||
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
|
||||
export class DesktopMandatoryUpdateWindow {
|
||||
private readonly embedded = process.platform === 'win32'
|
||||
private readonly embeddedParent: BrowserWindow | undefined
|
||||
private readonly publishEmbedded = (): void => { this.sync() }
|
||||
private embeddedParent: BrowserWindow | undefined
|
||||
private embeddedBlocking = false
|
||||
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
|
||||
private window: BrowserWindow | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | undefined
|
||||
private disposed = false
|
||||
@@ -143,6 +144,18 @@ 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) {
|
||||
const parent = this.options.parent()
|
||||
if (parent !== this.embeddedParent) {
|
||||
if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) {
|
||||
this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded)
|
||||
}
|
||||
this.embeddedBlocking = false
|
||||
this.embeddedParent = parent
|
||||
if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded)
|
||||
}
|
||||
if (parent === undefined || parent.isDestroyed()) return
|
||||
}
|
||||
if (!this.options.policy().blocking) {
|
||||
this.finishConfirmation(false)
|
||||
this.attention.reset()
|
||||
@@ -159,7 +172,10 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}, 150)
|
||||
}
|
||||
this.error = undefined
|
||||
if (this.embedded) this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
if (this.embedded && this.embeddedBlocking) {
|
||||
this.embeddedBlocking = false
|
||||
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
}
|
||||
return
|
||||
}
|
||||
clearTimeout(this.closing)
|
||||
@@ -167,6 +183,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (this.navigationUrl !== this.options.policy().page) this.clearNavigation()
|
||||
if (this.options.update().phase === 'error') this.restart = undefined
|
||||
if (this.embedded) {
|
||||
this.embeddedBlocking = true
|
||||
this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
return
|
||||
}
|
||||
@@ -198,6 +215,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
focus(): void {
|
||||
this.sync()
|
||||
const parent = this.options.parent()
|
||||
if (parent?.isDestroyed()) return
|
||||
if (parent?.isMinimized()) parent.restore()
|
||||
parent?.show()
|
||||
if (this.embedded) parent?.focus()
|
||||
@@ -276,10 +294,11 @@ export class DesktopMandatoryUpdateWindow {
|
||||
private assertSender(event: IpcMainInvokeEvent): void {
|
||||
if (this.embedded) {
|
||||
const parent = this.options.parent()
|
||||
if (parent === undefined || event.sender !== parent.webContents || event.senderFrame !== parent.webContents.mainFrame
|
||||
|| !['dsh-app://app/', 'dsh-app://app/index.html'].includes(event.senderFrame.url)) {
|
||||
if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents
|
||||
|| event.senderFrame !== parent.webContents.mainFrame) {
|
||||
throw new Error('desktop policy: rejected unowned renderer')
|
||||
}
|
||||
assertDesktopSender(event, ['app'])
|
||||
return
|
||||
}
|
||||
if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame
|
||||
|
||||
@@ -4,22 +4,26 @@ import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
|
||||
import type { MandatoryUpdateView } from './mandatory-update-window.ts'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
|
||||
/** Keep privileged update actions in the isolated preload, accepting messages only from its owned shell frame. */
|
||||
/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */
|
||||
export function installMandatoryUpdateOverlay(): void {
|
||||
let disposed = false
|
||||
let state: MandatoryUpdateView | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
let frame: HTMLIFrameElement | undefined
|
||||
let port: MessagePort | undefined
|
||||
let closing: ReturnType<typeof setTimeout> | undefined
|
||||
const publish = (): void => { frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-state', state }, 'dsh-app://shell') }
|
||||
const remove = (): void => { host?.remove(); host = undefined; frame = undefined }
|
||||
const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) }
|
||||
const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined }
|
||||
const render = (): void => {
|
||||
if (state === undefined || document.readyState === 'loading') return
|
||||
clearTimeout(closing)
|
||||
if (!state.policy.blocking) {
|
||||
if (frame === undefined || closing !== undefined) return
|
||||
publish()
|
||||
closing = setTimeout(remove, 150)
|
||||
return
|
||||
}
|
||||
clearTimeout(closing)
|
||||
closing = undefined
|
||||
if (frame === undefined) {
|
||||
host = document.createElement('div')
|
||||
host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)`
|
||||
@@ -30,39 +34,51 @@ export function installMandatoryUpdateOverlay(): void {
|
||||
frame.src = 'dsh-app://shell/mandatory-update.html'
|
||||
shadow.append(frame)
|
||||
document.documentElement.append(host)
|
||||
frame.addEventListener('load', () => { publish(); frame?.focus() })
|
||||
frame.addEventListener('load', () => {
|
||||
port?.close()
|
||||
const channel = new MessageChannel()
|
||||
port = channel.port1
|
||||
port.onmessage = message
|
||||
frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
|
||||
publish()
|
||||
frame?.focus()
|
||||
})
|
||||
}
|
||||
publish()
|
||||
}
|
||||
const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() }
|
||||
const message = (event: MessageEvent<unknown>): void => {
|
||||
if (frame === undefined || frame.contentWindow === null || event.source !== frame.contentWindow || event.origin !== 'dsh-app://shell') return
|
||||
const value = event.data
|
||||
if (typeof value !== 'object' || value === null || !('type' in value)) return
|
||||
if (value.type === 'dsh-mandatory-ready') { publish(); return }
|
||||
if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id)
|
||||
|| !('action' in value) || !('version' in value) || !('revision' in value)) return
|
||||
const target = frame.contentWindow
|
||||
const target = port
|
||||
if (target === undefined) return
|
||||
void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then(
|
||||
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }, 'dsh-app://shell') },
|
||||
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }, 'dsh-app://shell') },
|
||||
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) },
|
||||
() => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) },
|
||||
)
|
||||
}
|
||||
const blockBackgroundKey = (event: KeyboardEvent): void => {
|
||||
if (frame === undefined || state?.policy.blocking !== true) return
|
||||
if (frame === undefined || state?.policy.blocking !== true
|
||||
|| event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
frame.focus()
|
||||
}
|
||||
ipcRenderer.on(MANDATORY_IPC.state, receive)
|
||||
window.addEventListener('message', message)
|
||||
void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => {
|
||||
if (!disposed && state === undefined) { state = initial; render() }
|
||||
}, () => {
|
||||
// Development may omit policy configuration; a later main-frame load still resynchronizes configured policy.
|
||||
})
|
||||
window.addEventListener('keydown', blockBackgroundKey, true)
|
||||
window.addEventListener('DOMContentLoaded', render, { once: true })
|
||||
window.addEventListener('pagehide', () => {
|
||||
disposed = true
|
||||
clearTimeout(closing)
|
||||
remove()
|
||||
ipcRenderer.off(MANDATORY_IPC.state, receive)
|
||||
window.removeEventListener('message', message)
|
||||
window.removeEventListener('keydown', blockBackgroundKey, true)
|
||||
window.removeEventListener('DOMContentLoaded', render)
|
||||
}, { once: true })
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
export async function mandatoryFrame(parent) {
|
||||
@@ -19,7 +20,7 @@ export async function mandatoryFrameDriver(parent) {
|
||||
if (typeof event.y !== 'number') return target.sendInputEvent(event)
|
||||
return target.debugger.sendCommand('Input.dispatchMouseEvent', {
|
||||
type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved',
|
||||
x: event.x, y: event.y + 40, button: event.button ?? 'none', clickCount: event.clickCount ?? 0,
|
||||
x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0,
|
||||
})
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js'
|
||||
/** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */
|
||||
import { mandatoryFrameDriver } from './mandatory-frame.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
@@ -298,7 +299,7 @@ async function qualify() {
|
||||
if (process.platform === 'win32') {
|
||||
const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })')
|
||||
const bounds = mainWindow.getContentBounds()
|
||||
assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - 40 })
|
||||
assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT })
|
||||
}
|
||||
mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
|
||||
@@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => {
|
||||
getZoomFactor: () => 1,
|
||||
focus: vi.fn(),
|
||||
sendInputEvent: vi.fn(),
|
||||
send: vi.fn(),
|
||||
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
|
||||
if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve()
|
||||
}),
|
||||
})
|
||||
readonly show = vi.fn()
|
||||
readonly hide = vi.fn()
|
||||
@@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => {
|
||||
readonly setSize = vi.fn()
|
||||
readonly setTitleBarOverlay = vi.fn()
|
||||
constructor(readonly options: { show: boolean; modal?: boolean }) {
|
||||
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve()
|
||||
super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this)
|
||||
}
|
||||
isDestroyed() { return this.destroyed }
|
||||
fullscreen = false
|
||||
@@ -468,7 +470,7 @@ describe('desktop main startup', () => {
|
||||
expect(testAuth.login).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the same blocking window and running Host after expired test login is cancelled', async () => {
|
||||
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
|
||||
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
|
||||
const request = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
@@ -479,7 +481,7 @@ describe('desktop main startup', () => {
|
||||
const host = await readyForUpdate()
|
||||
await harness.policyBlocked.promise
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const modal = harness.windows.find(window => window.options.modal)!
|
||||
const modal = harness.windows[0]!
|
||||
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
||||
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
|
||||
testAuth.login.mockResolvedValueOnce('cancelled')
|
||||
@@ -851,14 +853,15 @@ describe('desktop main startup', () => {
|
||||
vi.stubGlobal('fetch', request)
|
||||
const host = await readyForUpdate()
|
||||
await harness.policyBlocked.promise
|
||||
const modal = harness.windows.find(window => window.options.modal)!
|
||||
const modal = harness.windows[0]!
|
||||
expect(modal).toBeDefined()
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
const status = harness.handlers.get(MANDATORY_IPC.status)!
|
||||
const action = harness.handlers.get(MANDATORY_IPC.action)!
|
||||
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
||||
expect(status(owned)).toMatchObject({ policy: { blocking: true } })
|
||||
const unowned = [
|
||||
{ ...owned, sender: harness.windows[0]!.webContents },
|
||||
{ ...owned, sender: {} },
|
||||
{ ...owned, senderFrame: { url: 'dsh-app://app/index.html' } },
|
||||
{ ...owned, senderFrame: { url: 'https://untrusted.example.com/' } },
|
||||
]
|
||||
@@ -883,7 +886,9 @@ describe('desktop main startup', () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(modal.isDestroyed()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(modal.isDestroyed()).toBe(true)
|
||||
expect(modal.isDestroyed()).toBe(false)
|
||||
expect(modal.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state,
|
||||
expect.objectContaining({ policy: expect.objectContaining({ blocking: false }) }))
|
||||
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' })
|
||||
})
|
||||
@@ -1099,7 +1104,7 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
|
||||
async function answerMandatory(action: 'install' | 'later') {
|
||||
const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())!
|
||||
const modal = harness.windows[0]!
|
||||
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
|
||||
@@ -1108,14 +1113,14 @@ describe('desktop main startup', () => {
|
||||
await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision)
|
||||
}
|
||||
|
||||
it('releases the mandatory modal when the confirmed installer quits Electron', async () => {
|
||||
it('closes the main window when the confirmed installer quits Electron', async () => {
|
||||
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 readyForUpdate()
|
||||
await harness.policyBlocked.promise
|
||||
const modal = harness.windows.find(window => window.options.modal)!
|
||||
const modal = harness.windows[0]!
|
||||
const preparing = harness.prepareUpdate()
|
||||
await answerMandatory('install')
|
||||
await host.stopping.promise
|
||||
@@ -1159,7 +1164,10 @@ describe('desktop main startup', () => {
|
||||
await expect(retry).resolves.toBe(false)
|
||||
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
|
||||
expect(replacement.stop).not.toHaveBeenCalled()
|
||||
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
|
||||
if (mandatory) {
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
expect(harness.windows[0]!.isDestroyed()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => {
|
||||
@@ -1193,7 +1201,10 @@ describe('desktop main startup', () => {
|
||||
await expect(retry).resolves.toBe(false)
|
||||
expect(replacement.updateTasks.mock.calls).toEqual([['inspect']])
|
||||
expect(replacement.stop).not.toHaveBeenCalled()
|
||||
if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false)
|
||||
if (mandatory) {
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
expect(harness.windows[0]!.isDestroyed()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replace a Host whose failed stop has not confirmed process exit', async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ it('releases IPC after the Windows main window has already been destroyed', () =
|
||||
setup('win32')
|
||||
vi.spyOn(window, 'isDestroyed').mockReturnValue(true)
|
||||
Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } })
|
||||
expect(() => { ui!.sync(); ui!.focus() }).not.toThrow()
|
||||
expect(() => { ui!.dispose() }).not.toThrow()
|
||||
expect(native.handlers.size).toBe(0)
|
||||
expect(() => { ui!.dispose() }).not.toThrow()
|
||||
@@ -179,3 +180,23 @@ it('publishes Windows overlays into the main document without creating or destro
|
||||
expect(window.destroy).not.toHaveBeenCalled()
|
||||
expect(window.webContents.listenerCount('did-finish-load')).toBe(0)
|
||||
})
|
||||
|
||||
it('rebinds policy updates and reloads to a replacement Windows main window', () => {
|
||||
setup('win32')
|
||||
const previous = window
|
||||
window = fakeWindow()
|
||||
window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home'
|
||||
ui!.sync()
|
||||
expect(previous.webContents.listenerCount('did-finish-load')).toBe(0)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith(MANDATORY_IPC.state,
|
||||
expect.objectContaining({ policy: expect.objectContaining({ blocking: true }) }))
|
||||
window.webContents.send.mockClear()
|
||||
window.webContents.emit('did-finish-load')
|
||||
expect(window.webContents.send).toHaveBeenCalledOnce()
|
||||
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
|
||||
sender: previous.webContents, senderFrame: previous.webContents.mainFrame,
|
||||
})).toThrow(/unowned/)
|
||||
expect(() => native.handlers.get(MANDATORY_IPC.status)!({
|
||||
sender: window.webContents, senderFrame: window.webContents.mainFrame,
|
||||
})).not.toThrow()
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
|
||||
import { syncWindowsAppearance } from '../src/preload-windows.ts'
|
||||
import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts'
|
||||
|
||||
@@ -12,7 +13,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
|
||||
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
|
||||
vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
|
||||
|
||||
it('limits product documents to update status and a native confirmation action', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/index.html'))
|
||||
@@ -77,3 +78,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp
|
||||
expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
|
||||
vi.resetModules()
|
||||
vi.mocked(installMandatoryUpdateOverlay).mockClear()
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
await import('../src/preload-app.ts')
|
||||
expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0)
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessageChannel } from 'node:worker_threads'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
|
||||
@@ -8,9 +9,12 @@ import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts'
|
||||
const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) }))
|
||||
vi.mock('electron', () => ({ ipcRenderer: ipc }))
|
||||
let dom: JSDOM
|
||||
let channel: MessageChannel
|
||||
afterEach(() => {
|
||||
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
|
||||
dom.window.close()
|
||||
channel?.port1.close()
|
||||
channel?.port2.close()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
@@ -18,48 +22,82 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
function setup() {
|
||||
dom = new JSDOM('<html><body><button>Product action</button></body></html>', { url: 'dsh-app://app/' })
|
||||
dom = new JSDOM('<html><body><button>Product action</button><div data-windows-menu></div></body></html>', { url: 'dsh-app://app/' })
|
||||
vi.stubGlobal('window', dom.window)
|
||||
vi.stubGlobal('document', dom.window.document)
|
||||
vi.stubGlobal('HTMLElement', dom.window.HTMLElement)
|
||||
vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel })
|
||||
vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete')
|
||||
const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow')
|
||||
installMandatoryUpdateOverlay()
|
||||
const publish = ipc.on.mock.calls.find(([channel]) => channel === MANDATORY_IPC.state)![1] as
|
||||
const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as
|
||||
(event: unknown, state: MandatoryUpdateView) => void
|
||||
const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false },
|
||||
update: { phase: 'available', version: '2.0.0' }, deferred: false }
|
||||
publish({}, view)
|
||||
ipc.invoke.mockClear()
|
||||
const root = shadow.mock.results[0]!.value as ShadowRoot
|
||||
const frame = root.querySelector('iframe')!
|
||||
// JSDOM does not create browsing contexts for frames inside a shadow root.
|
||||
// JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports.
|
||||
const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {})
|
||||
Object.defineProperty(frame, 'contentWindow', { value: dom.window })
|
||||
vi.spyOn(frame, 'focus').mockImplementation(() => {})
|
||||
frame.dispatchEvent(new dom.window.Event('load'))
|
||||
expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2])
|
||||
return { publish, view, root, frame, host: root.host as HTMLElement }
|
||||
}
|
||||
|
||||
it('uses an in-page frame below the caption, reuses it on updates, and never blurs the main body', () => {
|
||||
it('uses an in-page frame below the caption and reuses it on updates', () => {
|
||||
const f = setup()
|
||||
expect(f.host.style.top).toBe('40px')
|
||||
expect(f.host.style.bottom).toBe('0px')
|
||||
expect(f.host.style.position).toBe('fixed')
|
||||
expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html')
|
||||
expect(dom.window.document.body.style.filter).toBe('')
|
||||
f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } })
|
||||
expect(f.root.querySelector('iframe')).toBe(f.frame)
|
||||
})
|
||||
|
||||
it('accepts actions only from the owned shell frame and leaves no overlay after clearance or navigation', async () => {
|
||||
vi.useFakeTimers()
|
||||
it('rejects forged window events and accepts actions only through the transferred private port', async () => {
|
||||
const f = setup()
|
||||
const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined }
|
||||
const send = (source: Window | null, origin: string) => dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source, origin, data }))
|
||||
send(null, 'dsh-app://shell')
|
||||
send(f.frame.contentWindow, 'https://other.example')
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data }))
|
||||
expect(ipc.invoke).not.toHaveBeenCalled()
|
||||
send(f.frame.contentWindow, 'dsh-app://shell')
|
||||
await Promise.resolve()
|
||||
const result = new Promise<unknown>(resolve => channel.port2.on('message', (message) => {
|
||||
if (message.type === 'dsh-mandatory-result') resolve(message)
|
||||
}))
|
||||
channel.port2.postMessage(data)
|
||||
await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true })
|
||||
expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined)
|
||||
f.publish({}, { ...f.view, policy: { blocking: false, checking: false } })
|
||||
vi.advanceTimersByTime(150)
|
||||
})
|
||||
|
||||
it('keeps caption menu keyboard events available while blocking background shortcuts', () => {
|
||||
const f = setup()
|
||||
const host = dom.window.document.querySelector('[data-windows-menu]')!
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
const button = dom.window.document.createElement('button')
|
||||
shadow.append(button)
|
||||
const received = vi.fn()
|
||||
button.addEventListener('keydown', received)
|
||||
for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) {
|
||||
const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
|
||||
button.dispatchEvent(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
expect(received).toHaveBeenCalledTimes(3)
|
||||
const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true })
|
||||
dom.window.document.body.dispatchEvent(background)
|
||||
expect(background.defaultPrevented).toBe(true)
|
||||
expect(f.frame.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => {
|
||||
vi.useFakeTimers()
|
||||
const f = setup()
|
||||
const cleared = { ...f.view, policy: { blocking: false, checking: false } }
|
||||
f.publish({}, cleared)
|
||||
vi.advanceTimersByTime(100)
|
||||
f.publish({}, cleared)
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(f.host.isConnected).toBe(false)
|
||||
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
|
||||
expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function))
|
||||
|
||||
Reference in new issue
Block a user