fix(desktop): preserve background update overlay focus

This commit is contained in:
winewill committed 2026-10-20 17:04:06 +08:00
1 parent 9e6692931c
commit bc3ef12560
8 files changed
+75 -10

No files matched your search

+2 -2
View File
@@ -57,8 +57,8 @@
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
/deepseek-harness-desktop/updates:
en: 87db37529b51da62
zh: 4fc0d752e437e7d6
en: ea11d5f8891972b6
zh: 8aca22fbfb771a53
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
+1 -1
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.
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Already visible overlays are not shown again on parent visibility notifications; overlays loading or reappearing behind another application do not activate the application. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
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. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
+1 -1
View File
@@ -271,7 +271,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。父窗口的可见性通知不会重复显示已可见的浮层;浮层在其他应用后方完成加载或恢复显示时,不会激活应用。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
+6 -2
View File
@@ -20,7 +20,8 @@ export class DesktopUpdateOverlays {
* @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.
* @returns A transparent child that follows its parent's bounds and visibility without background activation;
* closing it releases its listeners.
*/
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
@@ -51,7 +52,10 @@ export class DesktopUpdateOverlays {
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()
if (!ready || window.isDestroyed() || parent.isDestroyed() || !parent.isVisible() || window.isVisible()) return
// macOS also emits show when an existing window becomes unoccluded.
if (parent.isFocused()) window.show()
else window.showInactive()
}
parent.on('show', show)
window.once('closed', () => { parent.off('show', show) })
@@ -11,5 +11,6 @@
"restored": { "parent": true, "overlay": true, "filter": "none" },
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
},
{ "parentDestroyed": true, "overlayDestroyed": true }
{ "parentDestroyed": true, "overlayDestroyed": true },
{ "backgroundOverlay": { "otherFocused": true, "overlayVisible": true } }
]
@@ -72,11 +72,39 @@ async function main() {
parent.destroy()
}
async function qualifyBackgroundVisibility() {
phase = 'background overlay load'
const parent = new BrowserWindow({ show: true, width: 800, height: 600 })
windows.push(parent)
await parent.loadURL('data:text/html,<h1>Overlay owner</h1>')
const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Background overlay', false)
windows.push(overlay)
const shown = once(overlay, 'show')
await overlay.loadURL('data:text/html,<h1>Update result</h1>')
await shown
const other = new BrowserWindow({ show: false, width: 600, height: 400 })
windows.push(other)
await other.loadURL('data:text/html,<h1>Other window</h1>')
phase = 'background overlay visibility notification'
const focused = once(other, 'focus')
other.show()
await focused
// macOS reports unocclusion through the same event as an explicit show.
parent.emit('show')
await overlay.webContents.executeJavaScript('document.readyState')
assert.equal(other.isFocused(), true, 'A parent visibility notification must not focus its existing overlay')
cases.push({ backgroundOverlay: { otherFocused: other.isFocused(), overlayVisible: overlay.isVisible() } })
other.destroy()
overlay.destroy()
parent.destroy()
}
try {
await app.whenReady()
await qualify(true)
await qualify(false)
await qualify(true, true)
await qualifyBackgroundVisibility()
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)
+2
View File
@@ -18,6 +18,7 @@ const fixture = await vi.hoisted(async () => {
removeInsertedCSS: vi.fn(async () => {}),
})
readonly focus = vi.fn()
readonly isFocused = vi.fn(() => true)
readonly show = vi.fn()
readonly hide = vi.fn()
readonly setBounds = vi.fn()
@@ -102,6 +103,7 @@ it('follows the parent geometry and removes listeners when closed or replaced',
const first = f.show()
const window = fixture.windows.at(-1)!
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 })
vi.spyOn(window, 'isVisible').mockReturnValue(false)
window.emit('ready-to-show')
expect(window.show).toHaveBeenCalledOnce()
f.parent.emit('resize')
+33 -3
View File
@@ -9,16 +9,19 @@ vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return
afterEach(() => { vi.restoreAllMocks() })
function visibilityFixture(visible = true) {
const visibility = { visible }
const visibility = { visible, focused: true }
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,
isFocused: () => visibility.focused,
})
const window = Object.assign(new EventEmitter(), {
destroyed: false,
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
destroyed: false, visible: false,
webContents: { setWindowOpenHandler: vi.fn() },
show: vi.fn(() => { window.visible = true }), showInactive: vi.fn(() => { window.visible = true }), focus: vi.fn(),
isVisible: () => window.visible,
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed,
})
native.create.mockReturnValue(window)
@@ -32,6 +35,7 @@ it('restores a ready overlay each time its parent is shown and releases visibili
expect(window.show).toHaveBeenCalledOnce()
for (let index = 0; index < 2; index++) {
visibility.visible = false
window.visible = false
parent.emit('hide')
visibility.visible = true
parent.emit('show')
@@ -49,6 +53,32 @@ it('restores a ready overlay each time its parent is shown and releases visibili
expect(window.show).toHaveBeenCalledTimes(3)
})
it('does not reactivate an already visible background overlay when macOS reports its parent as unoccluded', () => {
const { parent, window, visibility } = visibilityFixture()
window.emit('ready-to-show')
visibility.focused = false
parent.emit('show')
expect(window.show).toHaveBeenCalledOnce()
expect(window.showInactive).not.toHaveBeenCalled()
expect(window.focus).not.toHaveBeenCalled()
})
it('shows a late-loading or restored overlay without activation while its parent is in the background', () => {
const { parent, window, visibility } = visibilityFixture()
visibility.focused = false
window.emit('ready-to-show')
expect(window.show).not.toHaveBeenCalled()
expect(window.showInactive).toHaveBeenCalledOnce()
visibility.visible = false
window.visible = false
parent.emit('hide')
visibility.visible = true
parent.emit('show')
expect(window.show).not.toHaveBeenCalled()
expect(window.showInactive).toHaveBeenCalledTimes(2)
expect(window.focus).not.toHaveBeenCalled()
})
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)