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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
111 changes: 101 additions & 10 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
shell,
nativeTheme,
Menu,
dialog,
type MenuItemConstructorOptions
} from 'electron'
import path from 'path'
Expand All @@ -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'
Expand Down Expand Up @@ -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')
Expand All @@ -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()
}
}
]
Expand Down Expand Up @@ -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<Electron.WebContents>()

/**
* 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

Expand Down Expand Up @@ -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
}
})

Expand Down Expand Up @@ -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()

Expand Down
7 changes: 6 additions & 1 deletion src/main/popoverManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
64 changes: 64 additions & 0 deletions src/main/quitFlow.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>
/** Injectable timer cancel (defaults to the global clearTimeout). */
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void
}

export class QuitFlow {
private armed = false
private timer: ReturnType<typeof setTimeout> | null = null
private readonly setTimer: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>
private readonly clearTimer: (handle: ReturnType<typeof setTimeout>) => 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
}
}
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/app/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,6 +20,7 @@ const getInitialView = () => {
case 'screen-preview':
case 'updater':
case 'model-gate':
case 'quit-hint':
return hash
default:
return 'main'
Expand Down Expand Up @@ -50,6 +52,8 @@ function AppContainer() {
return <ActionsPanel />
case 'updater':
return <UpdaterPanel />
case 'quit-hint':
return <QuitHintPanel />
case 'model-gate':
return <ModelGatePopover />
default:
Expand Down
51 changes: 51 additions & 0 deletions src/renderer/src/components/QuitHintPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnimatePresence onExitComplete={handleAnimationComplete}>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="flex items-center justify-center w-full h-full p-2 glass-popover select-none"
>
{/* 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). */}
<p className="flex items-center h-8 text-sm font-medium text-black">{message}</p>
</motion.div>
)}
</AnimatePresence>
)
}
3 changes: 3 additions & 0 deletions src/renderer/src/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -236,6 +237,7 @@ export const translations = {
'zh-TW': {
// Add Traditional Chinese translations here
greeting: '您好',
quitHint: '再次按 {shortcut} 結束 Knovy',
// ControlPanel Status
PreviewPanelTitle: '螢幕預覽',
systemAudioLabel: '系統音訊',
Expand Down Expand Up @@ -670,6 +672,7 @@ export type TranslationKey =
| 'actionQueueTitle'
| 'noActionsInQueue'
| 'generatingResponse'
| 'quitHint'

/**
* Type definition for supported language codes in the application
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>
on: (channel: string, callback: (...args: any[]) => void) => () => void
Expand Down
Loading
Loading