diff --git a/src/main/core/doubleTapManager.ts b/src/main/core/doubleTapManager.ts index 5a1bacbf..82fbf885 100644 --- a/src/main/core/doubleTapManager.ts +++ b/src/main/core/doubleTapManager.ts @@ -1,10 +1,13 @@ -import { uIOhook, UiohookKey } from 'uiohook-napi' +import { UiohookKey } from 'uiohook-napi' +import globalInputManager from './globalInputManager.js' interface DoubleTapHandler { modifier: string callback: () => void } +const INPUT_CONSUMER = 'double-tap' + // uiohook keycode → 修饰键名称映射 const MODIFIER_KEYCODES: Record = { [UiohookKey.Meta]: 'Command', @@ -82,33 +85,30 @@ class DoubleTapManager { // 只注册一次事件监听器,避免重复注册导致事件多次触发 if (!this.listenersRegistered) { this.listenersRegistered = true - uIOhook.on('keydown', (e) => this.handleKeyDown(e)) - uIOhook.on('keyup', (e) => this.handleKeyUp(e)) + globalInputManager.on(INPUT_CONSUMER, 'keydown', (e) => this.handleKeyDown(e)) + globalInputManager.on(INPUT_CONSUMER, 'keyup', (e) => this.handleKeyUp(e)) } - try { - uIOhook.start() + if (globalInputManager.acquire(INPUT_CONSUMER)) { console.log('[DoubleTapManager] 全局键盘监听已启动') - } catch (error) { - console.error('[DoubleTapManager] 启动全局键盘监听失败:', error) + } else { this.started = false } } private stop(): void { if (!this.started) return - try { - uIOhook.stop() - console.log('[DoubleTapManager] 全局键盘监听已停止') - } catch (error) { - console.error('[DoubleTapManager] 停止全局键盘监听失败:', error) - } + globalInputManager.release(INPUT_CONSUMER) + console.log('[DoubleTapManager] 全局键盘监听已停止') this.started = false + this.listenersRegistered = false this.lastModifierUp = null this.nonModifierPressed = false } private handleKeyDown(e: { keycode: number }): void { + if (!this.started) return + const modifier = MODIFIER_KEYCODES[e.keycode] if (modifier) { if (this.modifierDownTime === 0) { @@ -122,6 +122,8 @@ class DoubleTapManager { } private handleKeyUp(e: { keycode: number }): void { + if (!this.started) return + const modifier = MODIFIER_KEYCODES[e.keycode] if (!modifier) { this.nonModifierPressed = false @@ -164,11 +166,16 @@ class DoubleTapManager { private fireHandlers(modifier: string): void { for (const handler of this.handlers) { if (handler.modifier === modifier) { - try { - handler.callback() - } catch (error) { - console.error(`[DoubleTapManager] 回调执行失败 (${modifier}):`, error) - } + // 避免在 uiohook 的 keyup 调用栈里直接 show/focus 窗口,降低 Windows 焦点竞争概率。 + setTimeout(() => { + if (!this.started) return + + try { + handler.callback() + } catch (error) { + console.error(`[DoubleTapManager] 回调执行失败 (${modifier}):`, error) + } + }, 0) } } } diff --git a/src/main/core/globalInputManager.ts b/src/main/core/globalInputManager.ts new file mode 100644 index 00000000..8236dc07 --- /dev/null +++ b/src/main/core/globalInputManager.ts @@ -0,0 +1,84 @@ +import { + uIOhook, + type UiohookKeyboardEvent, + type UiohookMouseEvent, + type UiohookWheelEvent +} from 'uiohook-napi' +import type { EventEmitter } from 'events' + +type GlobalInputEventMap = { + input: UiohookKeyboardEvent | UiohookMouseEvent | UiohookWheelEvent + keydown: UiohookKeyboardEvent + keyup: UiohookKeyboardEvent + mousedown: UiohookMouseEvent + mouseup: UiohookMouseEvent + mousemove: UiohookMouseEvent + click: UiohookMouseEvent + wheel: UiohookWheelEvent +} + +class GlobalInputManager { + // uIOhook 是进程级单例。用 consumer 引用计数管理 start/stop,避免一个模块 stop 掉其他模块的监听。 + private consumers = new Set() + // listener 按 consumer 归属记录,release 时只 off 当前模块注册的事件。 + private listenersByConsumer = new Map< + string, + Array<{ + event: keyof GlobalInputEventMap + listener: (...args: unknown[]) => void + }> + >() + private started = false + + public on( + consumer: string, + event: K, + listener: (event: GlobalInputEventMap[K]) => void + ): void { + const eventListener = listener as (...args: unknown[]) => void + ;(uIOhook as EventEmitter).on(event, eventListener) + + const listeners = this.listenersByConsumer.get(consumer) ?? [] + listeners.push({ event, listener: eventListener }) + this.listenersByConsumer.set(consumer, listeners) + } + + public acquire(consumer: string): boolean { + this.consumers.add(consumer) + if (this.started) return true + + try { + uIOhook.start() + this.started = true + console.log('[GlobalInput] 全局输入监听已启动') + return true + } catch (error) { + this.consumers.delete(consumer) + console.error('[GlobalInput] 启动全局输入监听失败:', error) + return false + } + } + + public release(consumer: string): void { + const listeners = this.listenersByConsumer.get(consumer) ?? [] + for (const { event, listener } of listeners) { + ;(uIOhook as EventEmitter).off(event, listener) + } + this.listenersByConsumer.delete(consumer) + + this.consumers.delete(consumer) + // 仍有其他模块依赖全局输入时,不能停止底层 uIOhook。 + if (!this.started || this.consumers.size > 0) return + + try { + uIOhook.stop() + console.log('[GlobalInput] 全局输入监听已停止') + } catch (error) { + console.error('[GlobalInput] 停止全局输入监听失败:', error) + } finally { + this.started = false + } + } +} + +export default new GlobalInputManager() diff --git a/src/main/managers/windowManager.ts b/src/main/managers/windowManager.ts index efdd4e22..d7b5fc18 100644 --- a/src/main/managers/windowManager.ts +++ b/src/main/managers/windowManager.ts @@ -17,6 +17,8 @@ import windowsIcon from '../../../resources/icons/windows-icon.png?asset' import api from '../api' import databaseAPI from '../api/shared/database' import doubleTapManager from '../core/doubleTapManager.js' +import globalInputManager from '../core/globalInputManager.js' +import { WindowManager as NativeWindowManager } from '../core/native/index.js' import clipboardManager from './clipboardManager' import { WINDOW_DEFAULT_HEIGHT, WINDOW_INITIAL_HEIGHT, WINDOW_WIDTH } from '../common/constants' @@ -27,6 +29,7 @@ import pluginManager from './pluginManager' // 窗口材质类型 type WindowMaterial = 'mica' | 'acrylic' | 'none' +const WINDOW_BLUR_DRAG_INPUT_CONSUMER = 'window-blur-drag' /** * 应用快捷键触发时携带的文件输入 @@ -82,6 +85,14 @@ class WindowManager { private suppressBlurHide: boolean = false // 临时抑制 blur 事件隐藏窗口(文件关联打开等场景) private lastBlurHideTime: number = 0 // blur 导致隐藏窗口的时间戳(用于解决托盘点击竞态) private blurHideTimer: ReturnType | null = null // Linux blur 延迟隐藏定时器 + // Double-tap 唤醒窗口时,Windows 可能紧跟一个短暂 blur;这两个 timer 用于跳过误关闭并补一次焦点。 + private doubleTapFocusTimer: ReturnType | null = null + private doubleTapSuppressBlurTimer: ReturnType | null = null + // 全局左键状态用于区分“点击外部关闭”和“从外部拖文件进窗口”。拖拽时 blur 先挂起,等 mouseup 再判断。 + private leftMouseDown: boolean = false // 全局左键是否按下,用于拖拽时延迟 blur 隐藏 + private pendingBlurHideOnMouseUp: boolean = false // blur 时左键按下,等待 mouseup 再决定是否隐藏 + private pendingBlurHideTimer: ReturnType | null = null // mouseup 兜底定时器 + private mouseStateTrackingStarted: boolean = false private appShortcuts: Map = new Map() // 应用快捷键映射表 (快捷键 -> 目标指令) private wakeupBlacklist: Array<{ app: string; bundleId?: string; label?: string }> = [] // 唤醒黑名单 private onThemeInfoChanged: (() => void) | null = null // 主题信息变更回调钩子 @@ -108,6 +119,95 @@ class WindowManager { this.mainWindow?.webContents.send('back-to-search') } + private isLeftMouseButton(button: unknown): boolean { + return Number(button) === 1 + } + + private isPointInsideMainWindow(point: { x: number; y: number }): boolean { + if (!this.mainWindow) return false + + const bounds = this.mainWindow.getBounds() + return ( + point.x >= bounds.x && + point.x <= bounds.x + bounds.width && + point.y >= bounds.y && + point.y <= bounds.y + bounds.height + ) + } + + private clearPendingBlurHideTimer(): void { + if (this.pendingBlurHideTimer) { + clearTimeout(this.pendingBlurHideTimer) + this.pendingBlurHideTimer = null + } + } + + private deferBlurHideUntilMouseUp(): void { + this.pendingBlurHideOnMouseUp = true + this.clearPendingBlurHideTimer() + + // 兜底:如果系统没有发出 mouseup,不让 pending 状态永久阻止窗口关闭。 + this.pendingBlurHideTimer = setTimeout(() => { + this.pendingBlurHideTimer = null + if (!this.pendingBlurHideOnMouseUp) return + + this.pendingBlurHideOnMouseUp = false + if (this.mainWindow?.isFocused()) return + if (pluginManager.isPluginViewFocused()) return + + this.lastBlurHideTime = Date.now() + this.hideWindow(false) + }, 15000) + } + + private resolveDeferredBlurHideOnMouseUp(): void { + this.pendingBlurHideOnMouseUp = false + this.clearPendingBlurHideTimer() + + this.resolveMouseUpVisibility() + } + + private resolveMouseUpVisibility(): void { + if (!this.mainWindow?.isVisible()) return + + // 拖拽最终落在窗口内时保持窗口;落在窗口外时按普通外部点击处理并关闭。 + const cursorPoint = screen.getCursorScreenPoint() + if (this.isPointInsideMainWindow(cursorPoint)) { + if (!this.mainWindow.isFocused() && !pluginManager.isPluginViewFocused()) { + this.mainWindow.focus() + } + return + } + + this.lastBlurHideTime = Date.now() + this.hideWindow(false) + } + + private startMouseStateTracking(): void { + if (this.mouseStateTrackingStarted) return + this.mouseStateTrackingStarted = true + + // 使用主进程的全局鼠标事件,而不是渲染层 drag 事件,因为 blur 会早于文件进入渲染层发生。 + globalInputManager.on(WINDOW_BLUR_DRAG_INPUT_CONSUMER, 'mousedown', (event) => { + if (this.isLeftMouseButton(event.button)) { + this.leftMouseDown = true + } + }) + + globalInputManager.on(WINDOW_BLUR_DRAG_INPUT_CONSUMER, 'mouseup', (event) => { + if (!this.isLeftMouseButton(event.button)) return + + this.leftMouseDown = false + if (this.pendingBlurHideOnMouseUp) { + this.resolveDeferredBlurHideOnMouseUp() + } else { + this.resolveMouseUpVisibility() + } + }) + + globalInputManager.acquire(WINDOW_BLUR_DRAG_INPUT_CONSUMER) + } + /** * 获取鼠标所在显示器的工作区尺寸和位置 */ @@ -294,6 +394,12 @@ class WindowManager { this.mainWindow.on('blur', () => { if (this.suppressBlurHide) return + // 左键仍按下时可能是从外部拖文件进窗口,先等 mouseup 再决定是否隐藏。 + if (this.leftMouseDown) { + this.deferBlurHideUntilMouseUp() + return + } + if (platform.isLinux) { // Linux 上去掉了 type:'panel',现在 blur 只会在真正点击其他窗口时触发。 // 但插件 WebContentsView 获焦仍会触发 blur,需延迟排除。 @@ -318,6 +424,8 @@ class WindowManager { } }) + this.startMouseStateTracking() + this.mainWindow.on('show', () => { // 开始恢复焦点流程,防止 focus 事件监听器修改 lastFocusTarget this.isRestoringFocus = true @@ -501,7 +609,7 @@ class WindowManager { if (this.isDoubleTapShortcut(keyToRegister)) { const modifier = keyToRegister.split('+')[0] doubleTapManager.register(modifier, () => { - this.toggleWindow() + this.toggleWindowFromDoubleTap() }) this.currentShortcut = keyToRegister this.isDoubleTapMode = true @@ -520,7 +628,7 @@ class WindowManager { if (oldIsDoubleTapMode) { const oldModifier = oldShortcut.split('+')[0] doubleTapManager.register(oldModifier, () => { - this.toggleWindow() + this.toggleWindowFromDoubleTap() }) } else { globalShortcut.register(oldShortcut, () => { @@ -598,6 +706,32 @@ class WindowManager { } } + private toggleWindowFromDoubleTap(): void { + if (!this.mainWindow) return + + 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.toggleWindow() + + if (willShow) { + if (this.doubleTapFocusTimer) clearTimeout(this.doubleTapFocusTimer) + // 延后一小段时间再聚焦,避开窗口 show 和系统焦点切换尚未稳定的阶段。 + this.doubleTapFocusTimer = setTimeout(() => { + this.refocusSearchAfterDoubleTap() + this.doubleTapFocusTimer = null + }, 80) + } + } + /** * 强制激活窗口(解决alert等弹窗后无法唤起的问题) */ @@ -620,6 +754,21 @@ class WindowManager { this.mainWindow.focus() } + private refocusSearchAfterDoubleTap(): void { + if (!this.mainWindow?.isVisible()) return + + app.focus({ steal: true }) + this.mainWindow.show() + this.mainWindow.moveTop() + if (platform.isWindows) { + // Electron 的 isFocused 有时已经为 true,但 Windows 前台键盘目标仍未切到本应用;这里用原生激活补齐。 + NativeWindowManager.activateWindow(process.pid) + } + this.mainWindow.focus() + this.mainWindow.webContents.focus() + this.mainWindow.webContents.send('focus-search', this.previousActiveWindow || null) + } + /** * 保存窗口位置到指定显示器(仅内存) */ @@ -877,6 +1026,8 @@ class WindowManager { public unregisterAllShortcuts(): void { globalShortcut.unregisterAll() doubleTapManager.unregisterAll() + globalInputManager.release(WINDOW_BLUR_DRAG_INPUT_CONSUMER) + this.mouseStateTrackingStarted = false this.isDoubleTapMode = false }