diff --git a/package.json b/package.json index cb52daf..1c36a75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Knovy", - "version": "0.3.11", + "version": "0.3.12", "private": true, "productName": "Knovy", "description": "Your All-in-One AI working assistant.", diff --git a/src/main/index.ts b/src/main/index.ts index c0a86d9..76e8a1d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,6 @@ import { shell, nativeTheme, Menu, - dialog, type MenuItemConstructorOptions } from 'electron' import path from 'path' @@ -28,6 +27,7 @@ import { getPopover } from './popoverManager' import { positionWindow, type PositionOptions } from './windowManager' +import { QuitFlow } from './quitFlow' import electronUpdater, { type AppUpdater } from 'electron-updater' import { withTimeout } from './utils/withTimeout' import { getWhisperBackend } from './whisperBackend' @@ -89,7 +89,8 @@ let selectionWindow: BrowserWindow | null /** * Set the application menu. - * cmd+Q shows a dialog directing the user to quit through the Settings panel. + * cmd+Q triggers the two-step quit flow: the first press shows a hint popover, + * a second press within a few seconds quits. */ function updateApplicationMenu(): void { console.log('[main/index.ts] Updating application menu') @@ -110,13 +111,7 @@ function updateApplicationMenu(): void { label: 'Quit', accelerator: 'Command+Q', click: () => { - dialog.showMessageBox({ - type: 'info', - title: 'Quit Application', - message: 'Please quit the application through the Settings panel.', - detail: 'You can also right-click the dock icon and select Quit.', - buttons: ['OK'] - }) + quitFlow.request() } } ] @@ -161,6 +156,91 @@ export function getAutoUpdater(): AppUpdater { // Other modules can get it from here if needed. export const getMainWindow = (): BrowserWindow | null => mainWindow +const QUIT_HINT_ID = 'quit-hint' +const QUIT_ARM_WINDOW_MS = 3000 + +/** Build the renderer URL for a popover hash route (dev server vs packaged file). */ +function buildPopoverUrl(hash: string): string { + const devServerUrl = import.meta.env['VITE_DEV_SERVER_URL'] + if (is.dev) { + const baseUrl = devServerUrl || 'http://localhost:5173' + return `${baseUrl}#${hash}` + } + return `file://${path.join(__dirname, '../renderer/index.html')}#${hash}` +} + +// Track webContents that already have the Ctrl+Q handler so re-attaching is a +// no-op. createPopover reuses an existing popover window of the same id, so the +// hint could otherwise accumulate duplicate listeners across re-arm cycles. +const quitShortcutAttached = new WeakSet() + +/** + * Attach Ctrl+Q two-step quit interception to a window (non-macOS only). + * R1: ignores key auto-repeat so holding Ctrl+Q does not trip the second press. + * macOS uses the app-menu accelerator instead, so this is a no-op there. + * Idempotent: attaching to the same webContents more than once is a no-op. + */ +function attachQuitShortcut(win: BrowserWindow): void { + if (process.platform === 'darwin') return + if (quitShortcutAttached.has(win.webContents)) return + quitShortcutAttached.add(win.webContents) + win.webContents.on('before-input-event', (event, input) => { + if ( + input.type === 'keyDown' && + !input.isAutoRepeat && + input.control && + !input.alt && + !input.meta && + !input.shift && + input.key.toLowerCase() === 'q' + ) { + event.preventDefault() + quitFlow.request() + } + }) +} + +/** + * Two-step quit. The first Cmd+Q (macOS) / Ctrl+Q (Windows/Linux) reveals the + * main window if hidden and shows a hint popover; a second press within + * QUIT_ARM_WINDOW_MS quits. The hint is created directly here so it does NOT + * disturb the user's other open popovers. + */ +const quitFlow = new QuitFlow({ + armWindowMs: QUIT_ARM_WINDOW_MS, + onShowHint: () => { + const win = mainWindow + if (!win || win.isDestroyed()) { + // No window to anchor the hint to — surface one. The flow stays armed, + // so a second press still quits. + createWindow() + return + } + if (win.isMinimized()) win.restore() + if (!win.isVisible()) win.show() + win.focus() + // Match the updater popover's geometry exactly: width 360 (440 while + // screen-sharing), height 50, centered 8px above the main bar (the 8px gap + // is createPopover's default when x/y are omitted). + const hint = createPopover({ + id: QUIT_HINT_ID, + parent: win, + url: buildPopoverUrl(QUIT_HINT_ID), + width: isScreenSharing ? 440 : 360, + height: 50 + }) + // R2: on Windows the hint can take keyboard focus, so the second Ctrl+Q + // could land here instead of the main window — intercept it on the hint too. + attachQuitShortcut(hint) + }, + onHideHint: () => { + closePopover(QUIT_HINT_ID) + }, + onQuit: () => { + app.quit() + } +}) + // Max time to wait for an update check before giving up const UPDATE_CHECK_TIMEOUT_MS = 10_000 @@ -539,7 +619,12 @@ const createWindow = async () => { contextIsolation: true, nodeIntegration: false, sandbox: false, - devTools: true // DevTools available in dev mode + devTools: true, // DevTools available in dev mode + // The overlay is a transparent, usually-unfocused HUD. Without this, + // Chromium throttles/pauses the renderer when it's backgrounded/occluded, + // so incoming transcription:data broadcasts don't paint until something + // wakes the window. Keep rendering live so transcripts show in real time. + backgroundThrottling: false } }) @@ -582,6 +667,12 @@ const createWindow = async () => { mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } + // Non-macOS: there is no app menu, so intercept Ctrl+Q on the focused window + // to drive the same two-step quit flow. (Global shortcuts would fire even when + // unfocused, which is wrong for quit.) The hint popover receives the same + // handler in onShowHint (R2); attachQuitShortcut is a no-op on macOS. + attachQuitShortcut(mainWindow) + // Center the window initially during loading mainWindow.center() diff --git a/src/main/popoverManager.ts b/src/main/popoverManager.ts index 4e88958..8ef3f5e 100644 --- a/src/main/popoverManager.ts +++ b/src/main/popoverManager.ts @@ -51,7 +51,12 @@ export function createPopover(options: PopoverOptions): BrowserWindow { preload: path.join(__dirname, '../preload/index.cjs'), contextIsolation: true, nodeIntegration: false, - sandbox: false + sandbox: false, + // This popover (e.g. the transcription panel) is a transparent, usually- + // unfocused overlay. Without this, Chromium throttles/pauses its renderer + // while it's backgrounded/occluded, so live transcription:data broadcasts + // don't paint until the window is woken. Keep it rendering in real time. + backgroundThrottling: false } }) console.log(`[PopoverManager] Created new BrowserWindow for id: ${id}`) diff --git a/src/main/quitFlow.ts b/src/main/quitFlow.ts new file mode 100644 index 0000000..bb67e85 --- /dev/null +++ b/src/main/quitFlow.ts @@ -0,0 +1,64 @@ +/** + * Two-step quit state machine. + * + * The first request() arms the flow and shows the hint popover. A second + * request() within `armWindowMs` quits. If no second request arrives in time, + * the flow disarms and hides the hint. All side effects are injected so the + * logic is unit-testable without Electron. + */ +export interface QuitFlowOptions { + /** How long (ms) the armed window stays open after the first request. */ + armWindowMs: number + /** Show the "press again to quit" hint (and reveal the window if hidden). */ + onShowHint: () => void + /** Hide the hint popover (timeout expiry path). */ + onHideHint: () => void + /** Actually quit the app. */ + onQuit: () => void + /** Injectable timer (defaults to the global setTimeout/clearTimeout). */ + setTimer?: (callback: () => void, ms: number) => ReturnType + /** Injectable timer cancel (defaults to the global clearTimeout). */ + clearTimer?: (handle: ReturnType) => void +} + +export class QuitFlow { + private armed = false + private timer: ReturnType | null = null + private readonly setTimer: (cb: () => void, ms: number) => ReturnType + private readonly clearTimer: (handle: ReturnType) => void + + constructor(private readonly options: QuitFlowOptions) { + this.setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms)) + this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)) + } + + /** True while armed (hint showing). */ + isArmed(): boolean { + return this.armed + } + + /** Handle a quit gesture (Cmd+Q / Ctrl+Q). */ + request(): void { + if (this.armed) { + this.disarm() + this.options.onQuit() + return + } + + this.armed = true + this.options.onShowHint() + this.timer = this.setTimer(() => { + this.armed = false + this.timer = null + this.options.onHideHint() + }, this.options.armWindowMs) + } + + private disarm(): void { + if (this.timer !== null) { + this.clearTimer(this.timer) + this.timer = null + } + this.armed = false + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 2c14259..69284b8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer } from 'electron' console.log('[Preload] Script loaded.') const api = { + platform: process.platform, + openExternal: (url: string) => ipcRenderer.send('electronAPI:openExternal', url), selectSource: (sourceId) => ipcRenderer.invoke('electronAPI:selectSource', sourceId), diff --git a/src/renderer/src/app/AppRouter.tsx b/src/renderer/src/app/AppRouter.tsx index 00228fc..dd4ce06 100644 --- a/src/renderer/src/app/AppRouter.tsx +++ b/src/renderer/src/app/AppRouter.tsx @@ -9,6 +9,7 @@ import ActionsPanel from '../components/ActionsPanel' import { PreviewPanel } from '../components/PreviewPanel' import { UpdaterPanel } from '../components/UpdaterPanel' import { ModelGatePopover } from '../components/ModelGatePopover' +import { QuitHintPanel } from '../components/QuitHintPanel' const getInitialView = () => { if (typeof window === 'undefined') return 'main' @@ -19,6 +20,7 @@ const getInitialView = () => { case 'screen-preview': case 'updater': case 'model-gate': + case 'quit-hint': return hash default: return 'main' @@ -50,6 +52,8 @@ function AppContainer() { return case 'updater': return + case 'quit-hint': + return case 'model-gate': return default: diff --git a/src/renderer/src/components/QuitHintPanel.tsx b/src/renderer/src/components/QuitHintPanel.tsx new file mode 100644 index 0000000..ab407ec --- /dev/null +++ b/src/renderer/src/components/QuitHintPanel.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import { AnimatePresence, motion } from 'motion' +import { useTranslation } from '@/context/TranslationContext' + +export function QuitHintPanel() { + const [isOpen, setIsOpen] = useState(true) + const { t } = useTranslation() + const popoverId = 'quit-hint' + + useEffect(() => { + const unsubscribe = window.electronAPI.on('popover:prepare-to-close', (id) => { + if (id === popoverId) { + setIsOpen(false) + } + }) + + return () => { + if (unsubscribe) { + unsubscribe() + } + } + }, []) + + const handleAnimationComplete = () => { + if (!isOpen) { + window.electronAPI.send('popover:ready-to-close', popoverId) + } + } + + const shortcut = window.electronAPI.platform === 'darwin' ? '⌘Q' : 'Ctrl+Q' + const message = t('quitHint').replace('{shortcut}', shortcut) + + return ( + + {isOpen && ( + + {/* h-8 matches the updater's button height so the bar is the same + height as the updater popover (h-full does not resolve — the bar + sizes to its content). */} +

{message}

+
+ )} +
+ ) +} diff --git a/src/renderer/src/lib/translations.ts b/src/renderer/src/lib/translations.ts index 33a4a29..791da57 100644 --- a/src/renderer/src/lib/translations.ts +++ b/src/renderer/src/lib/translations.ts @@ -14,6 +14,7 @@ export const translations = { 'en-US': { // Add English translations here greeting: 'Hello', + quitHint: 'Press {shortcut} again to quit', // ControlPanel Status PreviewPanelTitle: 'Screen Preview', systemAudioLabel: 'System Audio', @@ -236,6 +237,7 @@ export const translations = { 'zh-TW': { // Add Traditional Chinese translations here greeting: '您好', + quitHint: '再次按 {shortcut} 結束 Knovy', // ControlPanel Status PreviewPanelTitle: '螢幕預覽', systemAudioLabel: '系統音訊', @@ -670,6 +672,7 @@ export type TranslationKey = | 'actionQueueTitle' | 'noActionsInQueue' | 'generatingResponse' + | 'quitHint' /** * Type definition for supported language codes in the application diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index c2e7ba0..47c24a5 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -21,6 +21,7 @@ export interface AIMessage { // Electron API types export interface ElectronAPI { + platform: string send: (channel: string, data?: any) => void invoke: (channel: string, data?: any) => Promise on: (channel: string, callback: (...args: any[]) => void) => () => void diff --git a/tests/quit-flow.test.ts b/tests/quit-flow.test.ts new file mode 100644 index 0000000..75e42c7 --- /dev/null +++ b/tests/quit-flow.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { QuitFlow } from '../src/main/quitFlow' + +describe('QuitFlow', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + function setup() { + const onShowHint = vi.fn() + const onHideHint = vi.fn() + const onQuit = vi.fn() + const flow = new QuitFlow({ armWindowMs: 3000, onShowHint, onHideHint, onQuit }) + return { flow, onShowHint, onHideHint, onQuit } + } + + it('shows the hint and arms on the first request', () => { + const { flow, onShowHint, onQuit } = setup() + flow.request() + expect(onShowHint).toHaveBeenCalledTimes(1) + expect(onQuit).not.toHaveBeenCalled() + expect(flow.isArmed()).toBe(true) + }) + + it('quits on a second request within the window', () => { + const { flow, onShowHint, onHideHint, onQuit } = setup() + flow.request() + vi.advanceTimersByTime(1000) + flow.request() + expect(onQuit).toHaveBeenCalledTimes(1) + expect(onShowHint).toHaveBeenCalledTimes(1) // hint not shown a second time + expect(onHideHint).not.toHaveBeenCalled() // quit path must not run the hide-hint callback + expect(flow.isArmed()).toBe(false) + }) + + it('disarms and hides the hint after the window expires', () => { + const { flow, onHideHint, onQuit } = setup() + flow.request() + vi.advanceTimersByTime(3000) + expect(onHideHint).toHaveBeenCalledTimes(1) + expect(onQuit).not.toHaveBeenCalled() + expect(flow.isArmed()).toBe(false) + }) + + it('re-arms (shows the hint again) when requested after expiry', () => { + const { flow, onShowHint } = setup() + flow.request() + vi.advanceTimersByTime(3000) + flow.request() + expect(onShowHint).toHaveBeenCalledTimes(2) + expect(flow.isArmed()).toBe(true) + }) + + it('does not quit when the second request arrives after expiry', () => { + const { flow, onQuit } = setup() + flow.request() + vi.advanceTimersByTime(3001) + flow.request() + expect(onQuit).not.toHaveBeenCalled() + }) +})