Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions src/main/managers/windowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ class WindowManager {
// Double-tap 唤醒窗口时,Windows 可能紧跟一个短暂 blur;这两个 timer 用于跳过误关闭并补一次焦点。
private doubleTapFocusTimer: ReturnType<typeof setTimeout> | null = null
private windowsHotkeyFocusTimer: ReturnType<typeof setTimeout> | null = null
private doubleTapSuppressBlurTimer: ReturnType<typeof setTimeout> | null = null
private transientBlurSuppressTimer: ReturnType<typeof setTimeout> | null = null
private transientBlurSuppressUntil: number = 0 // 瞬时 blur 抑制截止时间戳,取最长值避免提前释放
// 全局左键状态用于区分“点击外部关闭”和“从外部拖文件进窗口”。拖拽时 blur 先挂起,等 mouseup 再判断。
private leftMouseDown: boolean = false // 全局左键是否按下,用于拖拽时延迟 blur 隐藏
private pendingBlurHideOnMouseUp: boolean = false // blur 时左键按下,等待 mouseup 再决定是否隐藏
Expand Down Expand Up @@ -161,6 +162,30 @@ class WindowManager {
return this.suppressBlurHide || this.modalDialogBlurHideSuppressed
}

/**
* 短暂抑制 blur 自动隐藏窗口。
*
* 窗口激活/呼出瞬间(尤其在其它应用处于全屏时抢焦点)系统可能补发一次瞬时 blur,
* 若不抑制会立刻触发 hideWindow 造成"一闪而过"。多次调用取最长的抑制时长,
* 避免后一次较短抑制把先前更长的抑制提前释放(例如双击唤醒与普通呼出叠加)。
*/
private suppressBlurHideTransiently(durationMs: number): void {
this.suppressBlurHide = true
const until = Date.now() + durationMs
if (this.transientBlurSuppressTimer && this.transientBlurSuppressUntil >= until) {
return
}
if (this.transientBlurSuppressTimer) {
clearTimeout(this.transientBlurSuppressTimer)
}
this.transientBlurSuppressUntil = until
this.transientBlurSuppressTimer = setTimeout(() => {
this.suppressBlurHide = false
this.transientBlurSuppressTimer = null
this.transientBlurSuppressUntil = 0
}, durationMs)
}
Comment on lines +172 to +187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前实现中,suppressBlurHideTransiently 直接修改了 this.suppressBlurHide 共享变量。这会与 openPluginInstaller 中手动设置的 this.suppressBlurHide = true 产生冲突和竞态(例如,瞬时抑制定时器触发时会错误地将 this.suppressBlurHide 置为 false,从而提前释放了 openPluginInstaller 的长时抑制)。

为了彻底解决这个问题,建议将瞬时抑制与长时抑制解耦:

  1. suppressBlurHideTransiently 中不再修改 this.suppressBlurHide
  2. 修改 isBlurHideSuppressed() 方法(在上方第 162 行),将 !!this.transientBlurSuppressTimer 纳入判断:
    private isBlurHideSuppressed(): boolean {
      return this.suppressBlurHide || this.modalDialogBlurHideSuppressed || !!this.transientBlurSuppressTimer
    }

这样不仅能避免竞态,还能让 openPluginInstaller 直接复用 forceActivateWindow(),消除重复代码。

  private suppressBlurHideTransiently(durationMs: number): void {
    const until = Date.now() + durationMs
    if (this.transientBlurSuppressTimer && this.transientBlurSuppressUntil >= until) {
      return
    }
    if (this.transientBlurSuppressTimer) {
      clearTimeout(this.transientBlurSuppressTimer)
    }
    this.transientBlurSuppressUntil = until
    this.transientBlurSuppressTimer = setTimeout(() => {
      this.transientBlurSuppressTimer = null
      this.transientBlurSuppressUntil = 0
    }, durationMs)
  }


private beginModalDialogBlurHideSuppression(): void {
if (this.modalDialogBlurHideReleaseTimer) {
clearTimeout(this.modalDialogBlurHideReleaseTimer)
Expand Down Expand Up @@ -756,12 +781,7 @@ class WindowManager {
const willShow = !(this.mainWindow.isFocused() && this.mainWindow.isVisible())
if (willShow) {
// Double-tap 的 uiohook 回调刚触发后,系统可能补发一次 transient blur,短暂忽略避免刚显示就关闭。
this.suppressBlurHide = true
if (this.doubleTapSuppressBlurTimer) clearTimeout(this.doubleTapSuppressBlurTimer)
this.doubleTapSuppressBlurTimer = setTimeout(() => {
this.suppressBlurHide = false
this.doubleTapSuppressBlurTimer = null
}, 350)
this.suppressBlurHideTransiently(350)
}

this.toggleWindow()
Expand All @@ -782,19 +802,22 @@ class WindowManager {
private forceActivateWindow(): void {
if (!this.mainWindow) return

// 1. 显示窗口
this.mainWindow.show()

// 2. macOS特殊处理:重申置顶,防止因为系统事件掉层级
// macOS:dock 隐藏的本应用属于 accessory,面板依靠 visibleOnFullScreen 才能覆盖全屏应用。
// 关键顺序:先把应用激活到当前 Space 再 show,否则面板会落到桌面 Space(表现为"一闪而过、
// 需到桌面查看")。同时短暂抑制 blur,避免抢焦点瞬间被全屏应用补发的瞬时 blur 立即关闭。
if (platform.isMacOS) {
this.suppressBlurHideTransiently(200)
this.mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
this.mainWindow.setAlwaysOnTop(true, 'modal-panel', 1)
app.focus({ steal: true })
this.mainWindow.show()
this.mainWindow.focus()
return
}

// 3. 设置窗口层级为最前
// Windows / Linux:显示后重申层级并聚焦
this.mainWindow.show()
this.mainWindow.setAlwaysOnTop(true)

// 4. 聚焦窗口
this.mainWindow.focus()
}

Expand Down Expand Up @@ -1415,9 +1438,15 @@ class WindowManager {
}

this.moveWindowToCursor()
// macOS: forceActivateWindow 不会 setAlwaysOnTop/focus,需要单独处理焦点抢占
this.mainWindow.show()
if (platform.isMacOS) {
// 与 forceActivateWindow 一致的激活顺序:先重申 Spaces/层级、激活应用到当前 Space,
// 再 show,避免面板落到桌面 Space。此处不直接调用 forceActivateWindow:本路径已用
// 外层手动 suppressBlurHide(500ms 释放)覆盖激活期,forceActivateWindow 内部的瞬时
// 抑制(200ms)会把它提前释放。
this.mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
this.mainWindow.setAlwaysOnTop(true, 'modal-panel', 1)
app.focus({ steal: true })
this.mainWindow.show()
this.mainWindow.focus()
} else {
this.forceActivateWindow()
Expand Down
215 changes: 215 additions & 0 deletions tests/main/windowManagerMacActivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

type MockHandler = (...args: any[]) => void

const mocks = vi.hoisted(() => {
const appFocus = vi.fn()
const dbGet = vi.fn()
const clipboardGetCurrentWindow = vi.fn()
const globalInputOn = vi.fn()
const globalInputAcquire = vi.fn()
const globalInputRelease = vi.fn()
const latestWindow = { current: null as any }

const createMockWindow = (): any => {
const handlers: Record<string, MockHandler[]> = {}
const webContentsHandlers: Record<string, MockHandler[]> = {}

const emit = (event: string, ...args: any[]): void => {
for (const handler of handlers[event] || []) {
handler(...args)
}
}

const win = {
webContents: {
setZoomFactor: vi.fn(),
setVisualZoomLevelLimits: vi.fn(),
on: vi.fn((event: string, handler: MockHandler) => {
;(webContentsHandlers[event] ||= []).push(handler)
}),
focus: vi.fn(),
send: vi.fn(),
getURL: vi.fn(() => 'app://ztools')
},
setVisibleOnAllWorkspaces: vi.fn(),
setAlwaysOnTop: vi.fn(),
setPosition: vi.fn(),
getPosition: vi.fn(() => [100, 100]),
getBounds: vi.fn(() => ({ x: 100, y: 100, width: 800, height: 600 })),
isFocused: vi.fn(() => false),
isVisible: vi.fn(() => false),
show: vi.fn(() => emit('show')),
hide: vi.fn(),
blur: vi.fn(),
focus: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn(),
on: vi.fn((event: string, handler: MockHandler) => {
;(handlers[event] ||= []).push(handler)
}),
once: vi.fn((event: string, handler: MockHandler) => {
;(handlers[event] ||= []).push(handler)
})
}

latestWindow.current = win
return win
}

return {
appFocus,
dbGet,
clipboardGetCurrentWindow,
globalInputOn,
globalInputAcquire,
globalInputRelease,
latestWindow,
createMockWindow
}
})

vi.mock('@electron-toolkit/utils', () => ({
is: { dev: false },
platform: { isMacOS: true, isWindows: false, isLinux: false }
}))

vi.mock('electron', () => ({
app: {
focus: mocks.appFocus,
dock: {
show: vi.fn(),
hide: vi.fn()
}
},
BrowserWindow: vi.fn(function BrowserWindowMock() {
return mocks.createMockWindow()
}),
globalShortcut: {
register: vi.fn(() => true),
unregister: vi.fn(),
unregisterAll: vi.fn(),
isRegistered: vi.fn(() => false)
},
Menu: {
buildFromTemplate: vi.fn(() => ({}))
},
nativeImage: {
createFromPath: vi.fn(() => ({
setTemplateImage: vi.fn()
}))
},
screen: {
getCursorScreenPoint: vi.fn(() => ({ x: 300, y: 300 })),
getDisplayNearestPoint: vi.fn(() => ({
id: 1,
workArea: { x: 0, y: 0, width: 1440, height: 900 }
}))
},
Tray: vi.fn(() => ({
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
on: vi.fn(),
popUpContextMenu: vi.fn()
}))
}))

vi.mock('../../src/main/api', () => ({
default: {
dbGet: vi.fn(() => null),
launchPlugin: vi.fn()
}
}))

vi.mock('../../src/main/api/shared/database', () => ({
default: {
dbGet: mocks.dbGet,
dbPut: vi.fn()
}
}))

vi.mock('../../src/main/core/doubleTapManager.js', () => ({
default: {
register: vi.fn(),
unregister: vi.fn(),
unregisterAll: vi.fn()
}
}))

vi.mock('../../src/main/core/globalInputManager.js', () => ({
default: {
on: mocks.globalInputOn,
acquire: mocks.globalInputAcquire,
release: mocks.globalInputRelease
}
}))

vi.mock('../../src/main/core/native/index.js', () => ({
WindowManager: {
activateWindow: vi.fn()
}
}))

vi.mock('../../src/main/managers/clipboardManager', () => ({
default: {
getCurrentWindow: mocks.clipboardGetCurrentWindow
}
}))

vi.mock('../../src/main/core/detachedWindowManager', () => ({
default: {
hasDetachedWindows: vi.fn(() => false)
}
}))

vi.mock('../../src/main/core/superPanelManager', () => ({
default: {
broadcastToSuperPanel: vi.fn()
}
}))

vi.mock('../../src/main/utils/windowUtils', () => ({
applyWindowMaterial: vi.fn(),
getDefaultWindowMaterial: vi.fn(() => 'none')
}))

vi.mock('../../src/main/managers/pluginManager', () => ({
default: {
getCurrentPluginPath: vi.fn(() => null),
restoreCurrentPluginViewHeightOnWindowShow: vi.fn(),
isPluginViewFocused: vi.fn(() => false),
focusPluginView: vi.fn(),
forceRepaintCurrentView: vi.fn(),
hidePluginView: vi.fn(),
handlePluginEsc: vi.fn(),
shouldSuppressMainHide: vi.fn(() => false)
}
}))

describe('windowManager macOS activation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.dbGet.mockReturnValue(null)
mocks.clipboardGetCurrentWindow.mockReturnValue(null)
mocks.latestWindow.current = null
})

it('activates and focuses the app when showing the main window on macOS', async () => {
const { default: windowManager } = await import('../../src/main/managers/windowManager')

windowManager.createWindow()
windowManager.showWindow()

expect(mocks.latestWindow.current.show).toHaveBeenCalled()
expect(mocks.latestWindow.current.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
visibleOnFullScreen: true
})
expect(mocks.latestWindow.current.setAlwaysOnTop).toHaveBeenLastCalledWith(
true,
'modal-panel',
1
)
expect(mocks.appFocus).toHaveBeenCalledWith({ steal: true })
expect(mocks.latestWindow.current.focus).toHaveBeenCalled()
})
})