Просмотр исходного кода

fix(desktop): make Windows mandatory update window movable

winewill 2 дней назад
Родитель
Сommit
b3795cb99f

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml

@@ -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-16-movable-mandatory-update-window.md
+2026-09-16-movable-mandatory-update-window.md: 99815ffa6cfe440363a8f4665debb428f6c93ef9
+2026-09-16-movable-mandatory-update-window.zh.md: 5ca15d69c521d2bc10009c63cf4cedd17f76b579

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md

@@ -0,0 +1,23 @@
+# Agent Note: Movable mandatory-update window
+
+Status: implemented
+
+English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
+
+## Problem
+
+The Windows mandatory-update page used a frameless modal overlay sized to the product window. The modal disabled its parent, so the parent's native title bar could not be used to move or maximize either window. Closing the overlay was intercepted, leaving no visible exit control.
+
+## Decision
+
+On Windows, mandatory policy uses a separate native framed modal with move, resize, and maximize controls. The parent remains disabled while policy blocks interaction. Closing the modal requests normal application shutdown; it never dismisses policy and resumes the product window. Other platforms retain the existing overlay presentation. The [mandatory-update decision](../feature/2026-09-11-desktop-mandatory-update-client.md) still owns policy and installation authorization.
+
+## Alternatives considered
+
+**Keep the full-content overlay and add a drag region.** A drag region would move the disabled parent indirectly and would not restore native maximize or close controls.
+
+**Let close dismiss the policy page.** That would expose the blocked product window without a fresh no-force policy response.
+
+## Consequences
+
+Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 可移动的强更窗口
+
+Status: implemented
+
+[English](2026-09-16-movable-mandatory-update-window.md) | 中文
+
+## Problem
+
+Windows 强更页面原先使用与产品窗口等大的无边框模态覆盖层。模态窗口禁用了父窗口,因此无法使用父窗口的原生标题栏移动或最大化窗口。覆盖层的关闭操作又被拦截,界面上没有可用的退出控件。
+
+## Decision
+
+在 Windows 上,强更策略使用独立的原生有边框模态窗口,支持移动、调整大小和最大化。策略阻塞期间父窗口仍不可操作。关闭模态窗口会请求应用正常退出,不会取消策略并恢复产品窗口。其他平台保留现有覆盖层展示。[强更决策](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)仍负责策略与安装授权。
+
+## Alternatives considered
+
+**保留覆盖整个内容区域的窗口并添加拖动区域。** 拖动区域只能间接移动被禁用的父窗口,也无法恢复原生最大化和关闭控件。
+
+**允许关闭时直接取消策略页面。** 这样会在未取得新的无需强更响应时暴露被阻塞的产品窗口。
+
+## Consequences
+
+Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 0ec72f3c5364866ff82111232ae3b61476ae4328
-README.zh.md: 5712f4686f3a317e11ea6b8d9364f98e6fd122b2
+README.md: d8cf12f614de3657ba4afedf337c7fb79d868d09
+README.zh.md: a29420ffbc0fe587ca70c9041cabc525b45e45c7

+ 1 - 1
apps/desktop/README.md

@@ -282,7 +282,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 content is plain text. Window close and Esc do not clear the block; application exit remains available through the system or menu. 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 content is plain text. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. 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.
 

+ 1 - 1
apps/desktop/README.zh.md

@@ -282,7 +282,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
 
 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
 
-扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端内容按纯文本展示。关闭窗口和 Esc 不解除阻塞;仍可通过系统或菜单退出应用。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
+扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端内容按纯文本展示。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
 
 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
 

+ 4 - 4
apps/desktop/src/mandatory-update-window.ts

@@ -1,11 +1,11 @@
 /** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
 
-import { BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
+import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
 import type { DesktopLocale } from './locale.ts'
 import 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'
+import { createMandatoryUpdateWindow } from './update-overlay.ts'
 import { DesktopUpdateAttention } from './update-attention.ts'
 
 /** A renderer action never carries a URL or authorizes a different version. */
@@ -151,10 +151,10 @@ export class DesktopMandatoryUpdateWindow {
     if (this.window === undefined) {
       const parent = this.options.parent()
       if (parent === undefined) return
-      const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle)
+      const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle)
       this.window = window
       window.setMenu(null)
-      window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); window.focus() } })
+      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 })
       window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
       window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })

+ 16 - 0
apps/desktop/src/update-overlay.ts

@@ -35,3 +35,19 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl
   window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
   return window
 }
+
+/** A native Windows modal retains its own title bar while the product window remains blocked. */
+export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string,
+  platform: NodeJS.Platform = process.platform): BrowserWindow {
+  if (platform !== 'win32') return createUpdateOverlay(parent, preload, title)
+  const window = new BrowserWindow({
+    parent, modal: true, show: false, title,
+    width: 640, height: 560, minWidth: 480, minHeight: 360,
+    movable: true, resizable: true, maximizable: true,
+    backgroundColor: '#f5f5f5',
+    webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
+  })
+  window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
+  window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+  return window
+}

+ 2 - 2
apps/desktop/tests/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/tests/README.md
-README.md: fb6399e2e65e51af2cf44d68765f0ea0843d44ea
-README.zh.md: 8cd8304ecf8e8c7edba2b2e2615a0fb18c394e5d
+README.md: f10e528b22f24a2bc4c41889e36b20cedf2829d9
+README.zh.md: f75aaf46571611f2f568996cb102f51ffb1bea89

+ 2 - 2
apps/desktop/tests/README.md

@@ -34,9 +34,9 @@ The local command builds Desktop and executes Electron 44 with the actual HTTP u
 | Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
 | Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
 | Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
-| Actual mandatory window | Text-only server content, close/Esc prevention, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass |
+| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
 | Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
-| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm close/Esc blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
+| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
 | Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
 | Host task protection | The actual controller with substituted composition detects running agents, queued turns/steps, and global and agent jobs; API reads do not warn. Admission locking returns 503 for new requests, drains existing requests, and rechecks tasks; unlock restores admission |
 | Visible output | Chinese mandatory-dialog DOM expectation and ordinary update presentation expectation pass; account-row component tests cover progress and persistent retry |

+ 2 - 2
apps/desktop/tests/README.zh.md

@@ -34,9 +34,9 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
 | 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
 | 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
 | 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
-| 真实强更窗口 | 服务端纯文本内容、关闭/Esc 拦截、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过 |
+| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
 | 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
-| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认关闭/Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
+| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
 | 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
 | Host 任务保护 | 替换组合环境后的真实控制器识别运行中的 agent、排队的 turn/step、全局和 agent job;API 读取不触发警告。请求准入锁定向新请求返回 503,等待已有请求结束并复查任务;解锁恢复准入 |
 | 可见输出 | 中文强更弹窗 DOM 预期输出和常规更新展示预期输出通过;账户行组件测试覆盖进度和持久重试 |

+ 12 - 4
apps/desktop/tests/fixtures/local-updater.mjs

@@ -278,8 +278,18 @@ async function main() {
         }
         await until("document.getElementById('title')?.textContent === '需要更新'")
         assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true)
-        window.close()
-        assert.equal(window.isDestroyed(), false)
+        if (process.platform === 'win32') {
+          assert.equal(window.isMovable(), true)
+          assert.equal(window.isResizable(), true)
+          assert.equal(window.isMaximizable(), true)
+          const bounds = window.getBounds()
+          window.setPosition(bounds.x + 20, bounds.y + 20)
+          assert.notDeepEqual(window.getBounds(), bounds)
+          window.maximize()
+          assert.equal(window.isMaximized(), true)
+          window.unmaximize()
+          assert.equal(window.isMaximized(), false)
+        }
         assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false)
         assert.equal(window.isDestroyed(), false)
         assert.equal(f.installations.length, 0)
@@ -341,8 +351,6 @@ async function main() {
           await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
           dialogScreenshots.push(name)
         }
-        window.close()
-        assert.equal(window.isDestroyed(), false)
         assert.equal(policy.state.blocking, true)
         assert.equal(f.installations.length, 0)
         server.policy('clear')

+ 10 - 3
apps/desktop/tests/fixtures/workspace-updates.mjs

@@ -272,9 +272,16 @@ async function qualify() {
     await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`)
     console.log('workspace qualification: mandatory title rendered')
     assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0)
-    mandatory.close()
-    console.log('workspace qualification: mandatory close refused')
-    assert.equal(mandatory.isDestroyed(), false)
+    assert.equal(mandatory.isMovable(), true)
+    assert.equal(mandatory.isResizable(), true)
+    assert.equal(mandatory.isMaximizable(), true)
+    const originalBounds = mandatory.getBounds()
+    mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20)
+    assert.notDeepEqual(mandatory.getBounds(), originalBounds)
+    mandatory.maximize()
+    await waitFor(() => mandatory.isMaximized(), 'mandatory maximize')
+    mandatory.unmaximize()
+    await waitFor(() => !mandatory.isMaximized(), 'mandatory restore')
     mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
     mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
     assert.equal((await control('status')).queued, 1)

+ 12 - 3
apps/desktop/tests/mandatory-update-window.spec.ts

@@ -8,12 +8,12 @@ import type { DesktopPolicyState } from '../src/mandatory-update-policy.ts'
 import type { DesktopUpdateState } from '../src/ipc.ts'
 
 const native = vi.hoisted(() => ({ handlers: new Map<string, (...args: unknown[]) => unknown>(),
-  open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn() }))
+  open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn(), quit: vi.fn() }))
 vi.mock('electron', () => ({ ipcMain: {
   handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
   removeHandler: (channel: string) => native.handlers.delete(channel),
-}, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
-vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: () => window }))
+}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
+vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window }))
 
 let window: ReturnType<typeof fakeWindow>
 let ui: DesktopMandatoryUpdateWindow | undefined
@@ -121,3 +121,12 @@ it('rejects same-URL child frames and removes quit from renderer privileges', ()
   expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/)
   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)
+})

+ 27 - 0
apps/desktop/tests/update-overlay.spec.ts

@@ -0,0 +1,27 @@
+import { EventEmitter } from 'node:events'
+import { expect, it, vi } from 'vitest'
+import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
+import { createMandatoryUpdateWindow } 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) } }))
+
+it('gives the Windows mandatory modal native move, resize, and maximize controls', () => {
+  const window = Object.assign(new EventEmitter(), {
+    webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false,
+  })
+  native.create.mockReturnValue(window)
+  const parent = {} as BrowserWindow
+  expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window)
+  expect(native.create).toHaveBeenCalledWith(expect.objectContaining({
+    parent, modal: true, show: false, title: 'Update required',
+    movable: true, resizable: true, maximizable: true,
+    minWidth: 480, minHeight: 360,
+  }))
+  const options = native.create.mock.calls[0]![0]
+  expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false })
+  expect(options).not.toHaveProperty('frame', false)
+  window.emit('ready-to-show')
+  expect(window.show).toHaveBeenCalledOnce()
+  expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce()
+})