Skip to content
Merged
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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "interview-coder-cn",
"version": "1.4.0",
"version": "1.4.2",
"description": "编码面试解题助手,实时截屏并生成解题思路和答案",
"main": "./out/main/index.js",
"scripts": {
Expand Down
17 changes: 12 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
102 changes: 96 additions & 6 deletions src/main/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,6 +10,7 @@ type Shortcut = {
action: string
key: string
status: ShortcutStatus
registeredKeys: string[]
}

enum ShortcutStatus {
Expand All @@ -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
Expand All @@ -46,7 +86,13 @@ const callbacks: Record<string, () => void> = {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
// 重新显示时不断重申置顶属性,抵消其他前台软件持续抢占
if (process.platform === 'darwin' || process.platform === 'win32') {
mainWindow.showInactive()
} else {
mainWindow.show()
}
keepWindowInFront(mainWindow)
}
},

Expand Down Expand Up @@ -194,16 +240,60 @@ const callbacks: Record<string, () => 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
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/coder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function CoderPage() {
return () => {
window.api.removeSyncAppStateListener()
}
}, [])
}, [syncAppState])

return (
<>
Expand Down
14 changes: 11 additions & 3 deletions src/renderer/src/lib/store/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ interface ShortcutsStore extends ShortcutsState {
resetShortcuts: () => void
}

type PersistedShortcutsState = {
shortcuts?: Record<string, Shortcut>
}

function isPersistedShortcutsState(value: unknown): value is PersistedShortcutsState {
return typeof value === 'object' && value !== null && 'shortcuts' in value
}

const defaultShortcuts: Record<string, Omit<Shortcut, 'defaultKey'>> = {
hideOrShowMainWindow: {
action: 'hideOrShowMainWindow',
Expand Down Expand Up @@ -101,8 +109,8 @@ export const useShortcutsStore = create<ShortcutsStore>()(
{
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]) => [
Expand All @@ -116,7 +124,7 @@ export const useShortcutsStore = create<ShortcutsStore>()(
...defaults,
...state.shortcuts
}
}
} as ShortcutsStore
}
}
)
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/src/lib/utils/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down