diff --git a/package-lock.json b/package-lock.json index 2eb5ed3..6bd49a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "interview-coder-cn", - "version": "1.3.0", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "interview-coder-cn", - "version": "1.3.0", + "version": "1.4.2", "hasInstallScript": true, "license": "CC BY-NC 4.0", "dependencies": { @@ -13266,9 +13266,9 @@ } }, "node_modules/vite": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", - "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13277,7 +13277,7 @@ "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", - "tinyglobby": "^0.2.14" + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index a7cbd62..958d04b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interview-coder-cn", - "version": "1.4.0", + "version": "1.4.2", "description": "编码面试解题助手,实时截屏并生成解题思路和答案", "main": "./out/main/index.js", "scripts": { diff --git a/src/main/index.ts b/src/main/index.ts index be210d4..75351bc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,13 +2,20 @@ import 'dotenv/config' import { app, BrowserWindow, globalShortcut, dialog } from 'electron' import { autoUpdater } from 'electron-updater' +type AbortLikeError = { + name?: string + code?: string + message?: unknown +} + // Swallow AbortError from user-initiated stream cancellations to keep console clean function isAbortError(error: unknown): boolean { - const err = error as any - return ( - !!err && - (err.name === 'AbortError' || err.code === 'ABORT_ERR' || /aborted/i.test(String(err.message))) - ) + if (typeof error !== 'object' || error === null) { + return false + } + const err = error as AbortLikeError + const message = typeof err.message === 'string' ? err.message : '' + return err.name === 'AbortError' || err.code === 'ABORT_ERR' || /aborted/i.test(message) } process.on('unhandledRejection', (error) => { diff --git a/src/main/shortcuts.ts b/src/main/shortcuts.ts index bdb98f3..7c16ee8 100644 --- a/src/main/shortcuts.ts +++ b/src/main/shortcuts.ts @@ -1,4 +1,5 @@ import { globalShortcut, ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' import type { ModelMessage } from 'ai' import { takeScreenshot } from './take-screenshot' import { getSolutionStream, getFollowUpStream } from './ai' @@ -9,6 +10,7 @@ type Shortcut = { action: string key: string status: ShortcutStatus + registeredKeys: string[] } enum ShortcutStatus { @@ -33,6 +35,44 @@ let currentStreamContext: StreamContext | null = null // Conversation history tracking let conversationMessages: ModelMessage[] = [] +const FRONT_REASSERT_DURATION = 5000 +const FRONT_REASSERT_INTERVAL = 150 +const FRONT_RELATIVE_LEVEL = 10 +let frontReassertTimer: NodeJS.Timeout | null = null + +function applyTopMost(win: BrowserWindow) { + if (!win || win.isDestroyed()) return + win.setAlwaysOnTop(true, 'screen-saver', FRONT_RELATIVE_LEVEL) + win.moveTop() +} + +function keepWindowInFront(window: BrowserWindow) { + if (!window || window.isDestroyed()) return + if (frontReassertTimer) { + clearInterval(frontReassertTimer) + frontReassertTimer = null + } + + const start = Date.now() + const reassert = () => { + if (!window.isVisible() || window.isDestroyed()) return false + applyTopMost(window) + return true + } + + if (!reassert()) return + + frontReassertTimer = setInterval(() => { + const shouldStop = Date.now() - start > FRONT_REASSERT_DURATION + if (shouldStop || !reassert()) { + if (frontReassertTimer) { + clearInterval(frontReassertTimer) + frontReassertTimer = null + } + } + }, FRONT_REASSERT_INTERVAL) +} + function abortCurrentStream(reason: AbortReason) { if (!currentStreamContext) return currentStreamContext.reason = reason @@ -46,7 +86,13 @@ const callbacks: Record void> = { if (mainWindow.isVisible()) { mainWindow.hide() } else { - mainWindow.show() + // 重新显示时不断重申置顶属性,抵消其他前台软件持续抢占 + if (process.platform === 'darwin' || process.platform === 'win32') { + mainWindow.showInactive() + } else { + mainWindow.show() + } + keepWindowInFront(mainWindow) } }, @@ -194,16 +240,60 @@ const callbacks: Record void> = { } } +function unregisterShortcut(action: string) { + const shortcut = shortcuts[action] + if (!shortcut) return + if (shortcut.registeredKeys.length) { + shortcut.registeredKeys.forEach((registeredKey) => { + globalShortcut.unregister(registeredKey) + }) + } else { + globalShortcut.unregister(shortcut.key) + } + shortcut.status = ShortcutStatus.Available + shortcut.registeredKeys = [] +} + +function getShortcutRegistrationKeys(key: string) { + const keys = [key] + if (process.platform !== 'win32') { + return keys + } + const parts = key.split('+') + const hasAlt = parts.includes('Alt') + const hasCtrl = parts.includes('CommandOrControl') || parts.includes('Control') + if (hasAlt && !hasCtrl) { + const aliasParts = [...parts] + const altIndex = aliasParts.indexOf('Alt') + if (altIndex >= 0) { + aliasParts.splice(altIndex, 0, 'CommandOrControl') + const aliasKey = aliasParts.join('+') + if (!keys.includes(aliasKey)) { + keys.push(aliasKey) + } + } + } + return keys +} + function registerShortcut(action: string, key: string) { - if (shortcuts[action]?.status === ShortcutStatus.Registered) { - globalShortcut.unregister(shortcuts[action].key) - shortcuts[action].status = ShortcutStatus.Available + if (shortcuts[action]) { + unregisterShortcut(action) } - const ok = globalShortcut.register(key, callbacks[action]) + + const keysToRegister = getShortcutRegistrationKeys(key) + const registeredKeys: string[] = [] + keysToRegister.forEach((shortcutKey) => { + if (globalShortcut.register(shortcutKey, callbacks[action])) { + registeredKeys.push(shortcutKey) + } + }) + shortcuts[action] = { action, key, - status: ok ? ShortcutStatus.Registered : ShortcutStatus.Failed + status: registeredKeys.length ? ShortcutStatus.Registered : ShortcutStatus.Failed, + registeredKeys } } diff --git a/src/renderer/src/coder/index.tsx b/src/renderer/src/coder/index.tsx index 39bf4a8..81c3daf 100644 --- a/src/renderer/src/coder/index.tsx +++ b/src/renderer/src/coder/index.tsx @@ -32,7 +32,7 @@ export default function CoderPage() { return () => { window.api.removeSyncAppStateListener() } - }, []) + }, [syncAppState]) return ( <> diff --git a/src/renderer/src/lib/store/shortcuts.ts b/src/renderer/src/lib/store/shortcuts.ts index 48e7d18..48e74b2 100644 --- a/src/renderer/src/lib/store/shortcuts.ts +++ b/src/renderer/src/lib/store/shortcuts.ts @@ -26,6 +26,14 @@ interface ShortcutsStore extends ShortcutsState { resetShortcuts: () => void } +type PersistedShortcutsState = { + shortcuts?: Record +} + +function isPersistedShortcutsState(value: unknown): value is PersistedShortcutsState { + return typeof value === 'object' && value !== null && 'shortcuts' in value +} + const defaultShortcuts: Record> = { hideOrShowMainWindow: { action: 'hideOrShowMainWindow', @@ -101,8 +109,8 @@ export const useShortcutsStore = create()( { name: 'interview-coder-shortcuts', version: 2, - migrate: (state: any) => { - if (!state?.shortcuts) return state + migrate: (state: unknown) => { + if (!isPersistedShortcutsState(state) || !state.shortcuts) return state as ShortcutsStore // Merge in any new default shortcuts that are missing const defaults = Object.fromEntries( Object.entries(defaultShortcuts).map(([action, shortcut]) => [ @@ -116,7 +124,7 @@ export const useShortcutsStore = create()( ...defaults, ...state.shortcuts } - } + } as ShortcutsStore } } ) diff --git a/src/renderer/src/lib/utils/keyboard.ts b/src/renderer/src/lib/utils/keyboard.ts index 74a9597..ac4ec8d 100644 --- a/src/renderer/src/lib/utils/keyboard.ts +++ b/src/renderer/src/lib/utils/keyboard.ts @@ -100,8 +100,14 @@ export function getShortcutAccelerator(event: KeyboardEvent) { } const modifiers: string[] = [] - if (event.ctrlKey) modifiers.push(isMac ? 'Control' : 'CommandOrControl') - if (event.altKey) modifiers.push('Alt') + // AltRight on Windows reports AltGraph and toggles ctrlKey, so treat it as plain Alt + const isAltGraph = + typeof event.getModifierState === 'function' && event.getModifierState('AltGraph') + const isCtrlActive = event.ctrlKey && !isAltGraph + const isAltActive = event.altKey || isAltGraph + + if (isCtrlActive) modifiers.push(isMac ? 'Control' : 'CommandOrControl') + if (isAltActive) modifiers.push('Alt') if (event.shiftKey) modifiers.push('Shift') if (event.metaKey) modifiers.push(isMac ? 'CommandOrControl' : 'Meta') if (modifiers.length === 0) return null