diff --git a/src/main/managers/windowManager.ts b/src/main/managers/windowManager.ts index 1ec3185f..43eb5647 100644 --- a/src/main/managers/windowManager.ts +++ b/src/main/managers/windowManager.ts @@ -102,7 +102,8 @@ class WindowManager { // Double-tap 唤醒窗口时,Windows 可能紧跟一个短暂 blur;这两个 timer 用于跳过误关闭并补一次焦点。 private doubleTapFocusTimer: ReturnType | null = null private windowsHotkeyFocusTimer: ReturnType | null = null - private doubleTapSuppressBlurTimer: ReturnType | null = null + private transientBlurSuppressTimer: ReturnType | null = null + private transientBlurSuppressUntil: number = 0 // 瞬时 blur 抑制截止时间戳,取最长值避免提前释放 // 全局左键状态用于区分“点击外部关闭”和“从外部拖文件进窗口”。拖拽时 blur 先挂起,等 mouseup 再判断。 private leftMouseDown: boolean = false // 全局左键是否按下,用于拖拽时延迟 blur 隐藏 private pendingBlurHideOnMouseUp: boolean = false // blur 时左键按下,等待 mouseup 再决定是否隐藏 @@ -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) + } + private beginModalDialogBlurHideSuppression(): void { if (this.modalDialogBlurHideReleaseTimer) { clearTimeout(this.modalDialogBlurHideReleaseTimer) @@ -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() @@ -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() } @@ -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() diff --git a/tests/main/windowManagerMacActivation.test.ts b/tests/main/windowManagerMacActivation.test.ts new file mode 100644 index 00000000..01351ae3 --- /dev/null +++ b/tests/main/windowManagerMacActivation.test.ts @@ -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 = {} + const webContentsHandlers: Record = {} + + 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() + }) +})