From fae7737b80a4915c783ed220cee7e103748cba05 Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 12:50:47 +0530 Subject: [PATCH 1/7] spike(single-app): run daemon + overlay in one Electron process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 proof of the single-app topology. A new src/app/main.ts hosts DaemonService and the overlay window in one Electron process; the daemon still emits state over its unix socket and the window's IPCClient connects in-process (loopback) — Phase 1 replaces that hop with webContents.send. Three env-gated seams make this bootable alongside the running daemon: - HYPRVOX_EMBEDDED_OVERLAY: overlay-process.start() no-ops (no child Electron) - HYPRVOX_SOCKET_PATH: alternate IPC socket (ipc.ts + overlay ipc-client) - HYPRVOX_PID_FILE: alternate daemon pidfile Verified: bundles via esbuild (ESM, electron + node-global-key-listener external), boots under Electron as a single PID, daemon + overlay + IPC all live, no second Electron spawned, running daemon untouched. --- overlay/src/ipc-client.ts | 4 +- src/app/main.ts | 146 ++++++++++++++++++++++++++++++++++ src/daemon/ipc.ts | 10 +-- src/daemon/overlay-process.ts | 5 ++ src/daemon/service.ts | 2 +- 5 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 src/app/main.ts diff --git a/overlay/src/ipc-client.ts b/overlay/src/ipc-client.ts index 138d033..fa69963 100644 --- a/overlay/src/ipc-client.ts +++ b/overlay/src/ipc-client.ts @@ -9,7 +9,9 @@ import type { IPCMessage, } from "./shared/ipc-types"; -const SOCKET_PATH = join(homedir(), ".config", "hypr", "vox", "daemon.sock"); +const SOCKET_PATH = + process.env.HYPRVOX_SOCKET_PATH || + join(homedir(), ".config", "hypr", "vox", "daemon.sock"); const INITIAL_RECONNECT_DELAY = 100; const MAX_RECONNECT_DELAY = 5000; const MAX_RECONNECT_ATTEMPTS = 10; diff --git a/src/app/main.ts b/src/app/main.ts new file mode 100644 index 0000000..18da24f --- /dev/null +++ b/src/app/main.ts @@ -0,0 +1,146 @@ +// Phase 0 spike: single-process topology. +// +// One Electron process hosts BOTH the daemon (DaemonService) and the overlay +// window, replacing the systemd -> supervisor -> daemon -> overlay-child stack. +// For this spike the daemon still emits state over its unix socket and the +// window's IPCClient connects to it in-process (loopback); Phase 1 replaces +// that hop with a direct webContents.send. The point here is only to prove the +// two halves boot and run inside a single process. +import * as path from "node:path"; +import { + app, + BrowserWindow, + type BrowserWindowConstructorOptions, + ipcMain, + screen, +} from "electron"; +import { DaemonService } from "../daemon/service"; +import { type DaemonState, getIPCClient, type IPCClient } from "../../overlay/src/ipc-client"; + +if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + console.error("[App] No display environment (DISPLAY or WAYLAND_DISPLAY)"); + process.exit(1); +} + +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch("disable-gpu"); +app.commandLine.appendSwitch("disable-software-rasterizer"); +app.commandLine.appendSwitch("disable-dev-shm-usage"); + +// Where the built renderer + preload live (overlay project's tsc/vite output). +const OVERLAY_DIST = + process.env.HYPRVOX_OVERLAY_DIST || + path.join(app.getAppPath(), "overlay", "dist"); + +const OVERLAY_CONFIG = { width: 400, height: 60, marginBottom: 80 }; + +let mainWindow: BrowserWindow | null = null; +let ipcClient: IPCClient | null = null; +let restingPosition = { x: 0, y: 0 }; +let parkedPosition = { x: 0, y: 0 }; +let overlayVisible = false; + +process.on("uncaughtException", (err) => console.error("[App] uncaught:", err)); +process.on("unhandledRejection", (r) => console.error("[App] unhandled:", r)); + +function createOverlayWindow(): BrowserWindow { + const display = screen.getPrimaryDisplay(); + const { width: sw, height: sh } = display.workArea; + const x = Math.floor((sw - OVERLAY_CONFIG.width) / 2); + const y = sh - OVERLAY_CONFIG.height - OVERLAY_CONFIG.marginBottom; + + restingPosition = { x, y }; + parkedPosition = { + x, + y: display.bounds.y + display.bounds.height + OVERLAY_CONFIG.height, + }; + overlayVisible = false; + + const opts: BrowserWindowConstructorOptions = { + width: OVERLAY_CONFIG.width, + height: OVERLAY_CONFIG.height, + x: parkedPosition.x, + y: parkedPosition.y, + frame: false, + transparent: true, + show: true, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + hasShadow: false, + focusable: false, + type: "toolbar", + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(OVERLAY_DIST, "preload.js"), + }, + }; + + const window = new BrowserWindow(opts); + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + window.setAlwaysOnTop(true, "floating"); + window.setIgnoreMouseEvents(true, { forward: true }); + window.loadFile(path.join(OVERLAY_DIST, "renderer", "index.html")); + return window; +} + +function setOverlayWindowVisible(visible: boolean): void { + if (!mainWindow || mainWindow.isDestroyed() || overlayVisible === visible) { + return; + } + overlayVisible = visible; + const target = visible ? restingPosition : parkedPosition; + mainWindow.setPosition(target.x, target.y); +} + +function setupIPCClient(): void { + ipcClient = getIPCClient(); + ipcClient.on("stateChange", (state: DaemonState) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("daemon-state", state); + } + }); + ipcClient.on("connectionStatusChange", (status) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("connection-status", status); + } + }); + ipcClient.on("audioLevel", (audioLevel) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("audio-level", audioLevel); + } + }); + ipcClient.on("connected", () => console.log("[App] IPC connected (in-process)")); + ipcClient.on("error", (err: Error) => console.error("[App] IPC error:", err.message)); + ipcClient.connect(); +} + +async function boot(): Promise { + // 1. Start the daemon in-process. It binds its socket, installs its own + // SIGUSR1 handler (so `hyprvox toggle` drives THIS process), and — with + // HYPRVOX_EMBEDDED_OVERLAY set — does NOT spawn a child overlay. + const service = new DaemonService(); + await service.start(); + console.log("[App] DaemonService started in-process, pid", process.pid); + + // 2. Bring up the window and connect to the daemon's socket in-process. + mainWindow = createOverlayWindow(); + mainWindow.on("closed", () => { + mainWindow = null; + }); + ipcMain.on("overlay-visible", (_e, visible: boolean) => + setOverlayWindowVisible(Boolean(visible)), + ); + ipcMain.handle("get-daemon-state", () => ipcClient?.state || { status: "idle" }); + ipcMain.handle("get-connection-status", () => ipcClient?.status || "disconnected"); + setupIPCClient(); + console.log("[App] App ready (single process)"); +} + +app.whenReady().then(boot); + +app.on("window-all-closed", () => { + // Single-app model: the window IS the app. Keep the process alive so the + // daemon and hotkey survive an overlay reload rather than quitting. +}); diff --git a/src/daemon/ipc.ts b/src/daemon/ipc.ts index 2a9a1f4..2a949e8 100644 --- a/src/daemon/ipc.ts +++ b/src/daemon/ipc.ts @@ -12,13 +12,9 @@ import type { DaemonStatus, IPCMessage } from "../shared/ipc-types"; import { logger } from "../utils/logger"; const IPC_PROTOCOL_VERSION = 1; -export const SOCKET_PATH = join( - homedir(), - ".config", - "hypr", - "vox", - "daemon.sock", -); +export const SOCKET_PATH = + process.env.HYPRVOX_SOCKET_PATH || + join(homedir(), ".config", "hypr", "vox", "daemon.sock"); export type { DaemonStatus, IPCMessage, ActionMessage } from "../shared/ipc-types"; diff --git a/src/daemon/overlay-process.ts b/src/daemon/overlay-process.ts index 157cd5c..825ff35 100644 --- a/src/daemon/overlay-process.ts +++ b/src/daemon/overlay-process.ts @@ -41,6 +41,11 @@ export class OverlayProcessManager { } public start(trigger: OverlayTrigger = "startup"): void { + // When the daemon runs inside the Electron main process (single-app + // topology), the overlay is the host — do not spawn a second Electron. + if (process.env.HYPRVOX_EMBEDDED_OVERLAY) { + return; + } if (!this.config.overlay?.enabled || !this.config.overlay?.autoStart) { return; } diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 34632cf..7fe1234 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -146,7 +146,7 @@ export class DaemonService { this.clipboard = new ClipboardManager(); this.ipcServer = getIPCServer(); const configDir = join(homedir(), ".config", "hypr", "vox"); - this.pidFile = join(configDir, "daemon.pid"); + this.pidFile = process.env.HYPRVOX_PID_FILE || join(configDir, "daemon.pid"); this.stateFile = join(configDir, "daemon.state"); this.overlay = new OverlayProcessManager( this.config, From 2da70238a885586c14afca5141e2c776b054610d Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 13:15:14 +0530 Subject: [PATCH 2/7] =?UTF-8?q?fix(single-app):=20survive=20Node/Electron?= =?UTF-8?q?=20runtime=20=E2=80=94=20dep=20audit=20fixes=20+=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the full dependency tree found four packages that cannot be inlined by a bundler (vendored binaries, dynamic require, package-relative asset paths): node-record-lpcm16, node-global-key-listener, clipboardy, plus a latent clipboardy xsel-fallback path break. Resolution: bundle only our own code (esbuild --packages=external); all deps resolve from node_modules at runtime, deleting the whole bug class. - recorder: default-import node-record-lpcm16 (CJS named export invisible to Node's ESM lexer; crashed under Electron, tolerated by Bun) - app main: boot failure exits instead of leaving a half-alive window; SIGTERM/SIGINT -> app.quit -> service.stop so the socket file is unlinked and the next boot cannot EADDRINUSE - tsconfig: exclude src/app (Electron types live in the overlay project; the app gets its own tsconfig in Phase 1) Verified end to end in one Electron process: SIGUSR1 trigger -> arecord -> Groq+Deepgram streaming -> merge/validation (silence hallucination correctly filtered) -> idle. 254 tests pass under bun; typecheck clean. --- src/app/main.ts | 39 ++++++++++++++++++++++++++++++++++++--- src/audio/recorder.ts | 8 +++++++- tsconfig.json | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/app/main.ts b/src/app/main.ts index 18da24f..b762e3f 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -36,6 +36,7 @@ const OVERLAY_CONFIG = { width: 400, height: 60, marginBottom: 80 }; let mainWindow: BrowserWindow | null = null; let ipcClient: IPCClient | null = null; +let service: DaemonService | null = null; let restingPosition = { x: 0, y: 0 }; let parkedPosition = { x: 0, y: 0 }; let overlayVisible = false; @@ -43,6 +44,11 @@ let overlayVisible = false; process.on("uncaughtException", (err) => console.error("[App] uncaught:", err)); process.on("unhandledRejection", (r) => console.error("[App] unhandled:", r)); +// Without these, a SIGTERM/SIGINT kills Electron before service.stop() runs, +// leaving a stale socket file that fails the next boot with EADDRINUSE. +process.on("SIGTERM", () => app.quit()); +process.on("SIGINT", () => app.quit()); + function createOverlayWindow(): BrowserWindow { const display = screen.getPrimaryDisplay(); const { width: sw, height: sh } = display.workArea; @@ -120,7 +126,14 @@ async function boot(): Promise { // 1. Start the daemon in-process. It binds its socket, installs its own // SIGUSR1 handler (so `hyprvox toggle` drives THIS process), and — with // HYPRVOX_EMBEDDED_OVERLAY set — does NOT spawn a child overlay. - const service = new DaemonService(); + console.log( + "[App] boot: pid=%d socket=%s pidfile=%s embedded=%s", + process.pid, + process.env.HYPRVOX_SOCKET_PATH || "(default)", + process.env.HYPRVOX_PID_FILE || "(default)", + process.env.HYPRVOX_EMBEDDED_OVERLAY || "(unset)", + ); + service = new DaemonService(); await service.start(); console.log("[App] DaemonService started in-process, pid", process.pid); @@ -129,7 +142,7 @@ async function boot(): Promise { mainWindow.on("closed", () => { mainWindow = null; }); - ipcMain.on("overlay-visible", (_e, visible: boolean) => + ipcMain.on("overlay-visible", (_e: unknown, visible: boolean) => setOverlayWindowVisible(Boolean(visible)), ); ipcMain.handle("get-daemon-state", () => ipcClient?.state || { status: "idle" }); @@ -138,7 +151,27 @@ async function boot(): Promise { console.log("[App] App ready (single process)"); } -app.whenReady().then(boot); +app.whenReady().then(() => + boot().catch((err) => { + // A half-alive app (window up, daemon dead) is worse than a dead one: + // it looks healthy while holding the pidfile and answering nothing. + console.error("[App] Boot failed, exiting:", err); + app.exit(1); + }), +); + +let stopping = false; +app.on("will-quit", (event: { preventDefault: () => void }) => { + if (stopping || !service) { + return; + } + stopping = true; + event.preventDefault(); + service + .stop() + .catch((err) => console.error("[App] Shutdown error:", err)) + .finally(() => app.exit(0)); +}); app.on("window-all-closed", () => { // Single-app model: the window IS the app. Keep the process alive so the diff --git a/src/audio/recorder.ts b/src/audio/recorder.ts index a2e610b..6911a21 100644 --- a/src/audio/recorder.ts +++ b/src/audio/recorder.ts @@ -1,6 +1,12 @@ import { execSync } from "node:child_process"; import { EventEmitter } from "node:events"; -import { type Recording, record } from "node-record-lpcm16"; +// Default-import + destructure: node-record-lpcm16 is CJS whose `record` +// export Node's ESM lexer cannot detect, so a named import crashes when this +// file runs under Node/Electron (Bun tolerates it). The default export is the +// full module.exports under both runtimes. +import recordPkg, { type Recording } from "node-record-lpcm16"; + +const { record } = recordPkg; import { loadConfig } from "../config/loader"; import { AppError, type ErrorCode, hasErrorCode } from "../utils/errors"; import { logError, logger } from "../utils/logger"; diff --git a/tsconfig.json b/tsconfig.json index 77ef342..11676e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "exclude": ["overlay"] + "exclude": ["overlay", "src/app"] } From 06223955a841a18be1e2b7305d156be8b046803b Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 14:38:41 +0530 Subject: [PATCH 3/7] refactor(paths): make root and socket-path resolution runtime-portable Replace Bun-only import.meta.dir in project-paths with a fileURLToPath-anchored walk up to the hyprvox package.json, so the resolver works under Electron/Node as well as Bun. Add socket-path util exporting HYPRVOX_SOCKET_PATH-aware daemon.sock location for the app command socket and CLI verbs to share. --- src/utils/project-paths.ts | 38 ++++++++++++++++++++++++++++++++++++-- src/utils/socket-path.ts | 8 ++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 src/utils/socket-path.ts diff --git a/src/utils/project-paths.ts b/src/utils/project-paths.ts index b425bef..cfd35e8 100644 --- a/src/utils/project-paths.ts +++ b/src/utils/project-paths.ts @@ -1,6 +1,40 @@ -import { join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -export const projectRoot = join(import.meta.dir, "..", ".."); +// Walk up from this module until the hyprvox package.json is found. The +// module's own directory is not a reliable anchor: under Bun this file runs +// from src/utils/, but the Electron app bundles it into dist/app/, and that +// directory carries its own package.json (named "hyprvox-overlay" for window +// identity), so a fixed "../.." would resolve to the wrong place. +function findProjectRoot(startDir: string): string { + let dir = startDir; + while (true) { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.name === "hyprvox") { + return dir; + } + } catch { + // Unreadable package.json — keep walking up. + } + } + const parent = dirname(dir); + if (parent === dir) { + // Filesystem root reached without a match; fall back to the source + // layout (src/utils -> repo root) so a broken install still points + // somewhere sensible. + return join(startDir, "..", ".."); + } + dir = parent; + } +} + +export const projectRoot = findProjectRoot( + dirname(fileURLToPath(import.meta.url)), +); export function getBundledOverlayPath(): string { return join(projectRoot, "overlay"); diff --git a/src/utils/socket-path.ts b/src/utils/socket-path.ts new file mode 100644 index 0000000..5f33a3f --- /dev/null +++ b/src/utils/socket-path.ts @@ -0,0 +1,8 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Command socket the app listens on and the CLI's socket verbs connect to. +// HYPRVOX_SOCKET_PATH lets a test instance run beside a live daemon. +export const SOCKET_PATH = + process.env.HYPRVOX_SOCKET_PATH || + join(homedir(), ".config", "hypr", "vox", "daemon.sock"); From cefe7f312fb9cf1c7b5389740b16c1b130abbc93 Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 14:38:50 +0530 Subject: [PATCH 4/7] refactor(single-app): collapse daemon and overlay into one Electron process Delete the supervision stack (supervisor, overlay-process manager, daemon IPC socket server, overlay ipc-client and its Electron main) and the crash-recovery integration test that exercised the supervisor. DaemonService now extends EventEmitter and emits state/audioLevel instead of pushing over a socket. src/app/main.ts hosts the daemon in-process, creates the overlay BrowserWindow, and forwards state to the renderer via webContents.send. command-server.ts binds the unix command socket for payload-carrying CLI verbs (soniox-toggle) and doubles as the single-instance guard. Overlay renderer/preload type imports repoint to shared/ipc-types. --- overlay/package.json | 38 +-- overlay/src/global.d.ts | 2 +- overlay/src/ipc-client.ts | 213 -------------- overlay/src/main.ts | 298 -------------------- overlay/src/preload.ts | 2 +- overlay/src/renderer/useDaemonState.ts | 2 +- src/app/command-server.ts | 129 +++++++++ src/app/main.ts | 182 ++++++++---- src/daemon/ipc.ts | 274 ------------------ src/daemon/overlay-process.ts | 337 ----------------------- src/daemon/service.ts | 72 ++--- src/daemon/supervisor.ts | 119 -------- tests/integration/crash_recovery.test.ts | 188 ------------- 13 files changed, 304 insertions(+), 1552 deletions(-) delete mode 100644 overlay/src/ipc-client.ts delete mode 100644 overlay/src/main.ts create mode 100644 src/app/command-server.ts delete mode 100644 src/daemon/ipc.ts delete mode 100644 src/daemon/overlay-process.ts delete mode 100644 src/daemon/supervisor.ts delete mode 100644 tests/integration/crash_recovery.test.ts diff --git a/overlay/package.json b/overlay/package.json index 7427395..c522b66 100644 --- a/overlay/package.json +++ b/overlay/package.json @@ -1,43 +1,10 @@ { "name": "hyprvox-overlay", "version": "0.1.0", - "description": "Waveform overlay for hyprvox", + "description": "Renderer and preload assets for the hyprvox overlay window (hosted by the single hyprvox Electron app)", "packageManager": "npm@10.9.4", - "main": "dist/main.js", "scripts": { - "build": "tsc && vite build", - "start": "electron .", - "dev": "npm run build && electron .", - "pack": "electron-builder --dir", - "dist": "electron-builder" - }, - "build": { - "appId": "com.hyprvox.overlay", - "productName": "Hyprvox Overlay", - "directories": { - "output": "release" - }, - "files": [ - "dist/**/*", - "package.json" - ], - "linux": { - "target": [ - { - "target": "AppImage", - "arch": [ - "x64" - ] - }, - { - "target": "tar.gz", - "arch": [ - "x64" - ] - } - ], - "category": "Utility" - } + "build": "tsc && vite build" }, "devDependencies": { "@types/node": "^22.0.0", @@ -45,7 +12,6 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "electron": "^34.0.0", - "electron-builder": "^25.1.8", "typescript": "^5.7.0", "vite": "^7.3.1" }, diff --git a/overlay/src/global.d.ts b/overlay/src/global.d.ts index a2a2066..fe9776f 100644 --- a/overlay/src/global.d.ts +++ b/overlay/src/global.d.ts @@ -2,7 +2,7 @@ import type { AudioLevelMessage, ConnectionStatus, DaemonState, -} from "./ipc-client"; +} from "./shared/ipc-types"; export interface ElectronAPI { onToggleListening: (callback: () => void) => () => void; diff --git a/overlay/src/ipc-client.ts b/overlay/src/ipc-client.ts deleted file mode 100644 index fa69963..0000000 --- a/overlay/src/ipc-client.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { EventEmitter } from "node:events"; -import { createConnection, type Socket } from "node:net"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { - AudioLevelMessage, - ConnectionStatus, - DaemonState, - IPCMessage, -} from "./shared/ipc-types"; - -const SOCKET_PATH = - process.env.HYPRVOX_SOCKET_PATH || - join(homedir(), ".config", "hypr", "vox", "daemon.sock"); -const INITIAL_RECONNECT_DELAY = 100; -const MAX_RECONNECT_DELAY = 5000; -const MAX_RECONNECT_ATTEMPTS = 10; - -export type { - AudioLevelMessage, - ConnectionStatus, - DaemonState, - DaemonStatus, - IPCMessage, -} from "./shared/ipc-types"; - -export class IPCClient extends EventEmitter { - private socket: Socket | null = null; - private buffer = ""; - private reconnectDelay = INITIAL_RECONNECT_DELAY; - private reconnectAttempts = 0; - private reconnectTimer: NodeJS.Timeout | null = null; - private connectionStatus: ConnectionStatus = "disconnected"; - private currentState: DaemonState = { status: "idle" }; - private _protocolVersion = 0; - private shouldReconnect = true; - private currentAudioLevel: AudioLevelMessage | null = null; - - get state(): DaemonState { - return this.currentState; - } - - get status(): ConnectionStatus { - return this.connectionStatus; - } - - get protocolVersion(): number { - return this._protocolVersion; - } - - get audioLevel(): AudioLevelMessage | null { - return this.currentAudioLevel; - } - - connect(): void { - if (this.socket) { - return; - } - - this.shouldReconnect = true; - this.setConnectionStatus("connecting"); - this.attemptConnection(); - } - - disconnect(): void { - this.shouldReconnect = false; - this.clearReconnectTimer(); - if (this.socket) { - this.socket.destroy(); - this.socket = null; - } - this.setConnectionStatus("disconnected"); - } - - private attemptConnection(): void { - this.socket = createConnection({ path: SOCKET_PATH }); - - this.socket.on("connect", () => { - this.reconnectDelay = INITIAL_RECONNECT_DELAY; - this.reconnectAttempts = 0; - this.setConnectionStatus("connected"); - this.emit("connected"); - }); - - this.socket.on("data", (chunk) => { - this.handleData(chunk); - }); - - this.socket.on("close", () => { - this.socket = null; - this.setConnectionStatus("disconnected"); - this.emit("disconnected"); - this.scheduleReconnect(); - }); - - this.socket.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ECONNREFUSED" || err.code === "ENOENT") { - this.emit("daemonUnavailable"); - } else { - // Only emit error if there's a listener, otherwise log it - // This prevents unhandled error crashes - if (this.listenerCount("error") > 0) { - this.emit("error", err); - } else { - console.error("[IPCClient] Unhandled socket error:", err.message); - } - } - }); - } - - private handleData(chunk: Buffer): void { - this.buffer += chunk.toString(); - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg: IPCMessage = JSON.parse(line); - this.handleMessage(msg); - } catch { - // Malformed JSON - skip - } - } - } - - private handleMessage(msg: IPCMessage): void { - const receivedAt = Date.now(); - const latency = - "timestamp" in msg && msg.timestamp ? receivedAt - msg.timestamp : null; - - if (latency !== null && process.env.HYPRVOX_DEBUG_TIMING) { - console.log(`[TIMING] IPC ${msg.type} received, latency=${latency}ms`); - } - - if (msg.type === "hello") { - this._protocolVersion = msg.version || 1; - if (msg.status) { - this.updateState({ - status: msg.status, - timestamp: msg.timestamp, - }); - } - this.emit("hello", msg); - } else if (msg.type === "state") { - this.updateState({ - status: msg.status || "idle", - lastTranscription: msg.lastTranscription, - error: msg.error, - timestamp: msg.timestamp, - }); - } else if (msg.type === "audio_level") { - this.currentAudioLevel = msg; - this.emit("audioLevel", msg); - } - } - - private updateState(state: DaemonState): void { - const oldStatus = this.currentState.status; - this.currentState = state; - this.emit("stateChange", state); - if (oldStatus !== state.status) { - this.emit("statusChange", state.status, oldStatus); - } - } - - private setConnectionStatus(status: ConnectionStatus): void { - if (this.connectionStatus !== status) { - this.connectionStatus = status; - this.emit("connectionStatusChange", status); - } - } - - private scheduleReconnect(): void { - if (!this.shouldReconnect) { - return; - } - - this.reconnectAttempts++; - if (this.reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { - this.emit("maxReconnectAttemptsReached"); - return; - } - - this.clearReconnectTimer(); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.setConnectionStatus("connecting"); - this.attemptConnection(); - }, this.reconnectDelay); - - this.reconnectDelay = Math.min( - this.reconnectDelay * 2, - MAX_RECONNECT_DELAY, - ); - } - - private clearReconnectTimer(): void { - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } -} - -let clientInstance: IPCClient | null = null; - -export function getIPCClient(): IPCClient { - if (!clientInstance) { - clientInstance = new IPCClient(); - } - return clientInstance; -} diff --git a/overlay/src/main.ts b/overlay/src/main.ts deleted file mode 100644 index 10ca837..0000000 --- a/overlay/src/main.ts +++ /dev/null @@ -1,298 +0,0 @@ -import * as path from "node:path"; -import { - app, - BrowserWindow, - type BrowserWindowConstructorOptions, - ipcMain, - screen, -} from "electron"; -import { type DaemonState, getIPCClient, type IPCClient } from "./ipc-client"; - -// Validate display environment before starting -if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - console.error( - "[Overlay] No display environment found (DISPLAY or WAYLAND_DISPLAY)", - ); - console.error("[Overlay] Overlay cannot start without a display server"); - process.exit(1); -} - -// Disable GPU acceleration to prevent rendering crashes on Wayland -// GPU-related crashes are the most common cause of overlay failures -app.disableHardwareAcceleration(); -app.commandLine.appendSwitch("disable-gpu"); -app.commandLine.appendSwitch("disable-software-rasterizer"); -app.commandLine.appendSwitch("disable-dev-shm-usage"); - -interface OverlayConfig { - width: number; - height: number; - marginBottom: number; -} - -const DEFAULT_CONFIG: OverlayConfig = { - width: 400, - height: 60, - marginBottom: 80, -}; - -const IPC_CONNECTION_TIMEOUT_MS = 5000; - -let mainWindow: BrowserWindow | null = null; -let ipcClient: IPCClient | null = null; -let previousStatus: string = "idle"; -let ipcConnectionTimeout: NodeJS.Timeout | null = null; -let restingPosition = { x: 0, y: 0 }; -let parkedPosition = { x: 0, y: 0 }; -let overlayVisible = false; - -process.on("uncaughtException", (err) => { - console.error("[Overlay] Uncaught exception:", err); -}); - -process.on("unhandledRejection", (reason) => { - console.error("[Overlay] Unhandled rejection:", reason); -}); - -function createOverlayWindow( - config: OverlayConfig = DEFAULT_CONFIG, -): BrowserWindow { - const display = screen.getPrimaryDisplay(); - // Use workArea to avoid placing overlay behind taskbar/panel - const { width: screenWidth, height: screenHeight } = display.workArea; - - const x = Math.floor((screenWidth - config.width) / 2); - const y = screenHeight - config.height - config.marginBottom; - - // Resting spot (visible) and a parked spot fully below the monitor. The - // window stays mapped, but when idle we move it off-screen instead of - // leaving it on the desktop, so the compositor has no transparent rect to - // blur/round into a "ghost pill" at idle. Moving an already-mapped window is - // cheap (no re-map) and the compositor animates it into view. - restingPosition = { x, y }; - parkedPosition = { - x, - y: display.bounds.y + display.bounds.height + config.height, - }; - overlayVisible = false; - - const windowOptions: BrowserWindowConstructorOptions = { - width: config.width, - height: config.height, - x: parkedPosition.x, - y: parkedPosition.y, - frame: false, - transparent: true, - show: true, - alwaysOnTop: true, - resizable: false, - skipTaskbar: true, - hasShadow: false, - focusable: false, - type: "toolbar", - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: path.join(__dirname, "preload.js"), - }, - }; - - const window = new BrowserWindow(windowOptions); - - window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - window.setAlwaysOnTop(true, "floating"); - window.setIgnoreMouseEvents(true, { forward: true }); - - window.loadFile(path.join(__dirname, "renderer", "index.html")); - - // Add crash recovery handlers - window.on("unresponsive", () => { - console.error("[Overlay] Window became unresponsive"); - if (mainWindow) { - mainWindow.destroy(); - mainWindow = null; - } - // Recreate window after delay - setTimeout(() => { - if (!mainWindow) { - console.log("[Overlay] Recreating window after unresponsive event"); - mainWindow = createOverlayWindow(); - // IPC client is a singleton already connected, no need to reinitialize - } - }, 2000); - }); - - window.webContents.on("render-process-gone", (_event, details) => { - console.error("[Overlay] Renderer process crashed", details); - if (mainWindow) { - mainWindow.destroy(); - mainWindow = null; - } - // Recreate window after delay - setTimeout(() => { - if (!mainWindow) { - console.log("[Overlay] Recreating window after crash"); - mainWindow = createOverlayWindow(); - // IPC client is a singleton already connected, no need to reinitialize - } - }, 2000); - }); - - return window; -} - -// Move the always-mapped window between its resting (visible) and parked -// (off-screen) positions. Driven by the renderer's own visibility, which -// already folds in success/error hold timers, so animations are never cut off. -function setOverlayWindowVisible(visible: boolean): void { - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - if (overlayVisible === visible) { - return; - } - overlayVisible = visible; - const target = visible ? restingPosition : parkedPosition; - mainWindow.setPosition(target.x, target.y); -} - -function setupIPCClient(): void { - // Set connection timeout - ipcConnectionTimeout = setTimeout(() => { - console.error( - "[Overlay] IPC connection timeout - daemon may not be running", - ); - app.quit(); - }, IPC_CONNECTION_TIMEOUT_MS); - - ipcClient = getIPCClient(); - - ipcClient.on("stateChange", (state: DaemonState) => { - const receivedAt = Date.now(); - const latency = state.timestamp ? receivedAt - state.timestamp : null; - - console.log(`[TIMING] State change: ${state.status}, latency=${latency}ms`); - - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - - mainWindow.webContents.send("daemon-state", state); - - const currentStatus = state.status; - - switch (currentStatus) { - case "idle": - if (previousStatus === "processing") { - console.log( - `[TIMING] Window already mapped (success), total=${state.timestamp ? Date.now() - state.timestamp : "N/A"}ms`, - ); - } - break; - - case "error": - console.log( - `[TIMING] Window already mapped (error), total=${state.timestamp ? Date.now() - state.timestamp : "N/A"}ms`, - ); - break; - - case "starting": - case "recording": - case "processing": - case "stopping": - console.log( - `[TIMING] Window already mapped (${currentStatus}), total=${state.timestamp ? Date.now() - state.timestamp : "N/A"}ms`, - ); - break; - } - - previousStatus = currentStatus; - }); - - ipcClient.on("connectionStatusChange", (status) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("connection-status", status); - } - }); - - ipcClient.on("audioLevel", (audioLevel) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("audio-level", audioLevel); - } - }); - - ipcClient.on("maxReconnectAttemptsReached", () => { - console.log("Max reconnect attempts reached, daemon unavailable"); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("reconnect-exhausted"); - } - }); - - ipcClient.on("connected", () => { - if (ipcConnectionTimeout) { - clearTimeout(ipcConnectionTimeout); - ipcConnectionTimeout = null; - } - console.log("[IPC] Connected to daemon"); - }); - - // Add error listener to prevent unhandled error crashes - ipcClient.on("error", (err: Error) => { - console.error("[IPC] Connection error:", err.message); - }); - - ipcClient.connect(); -} - -app.whenReady().then(() => { - mainWindow = createOverlayWindow(); - console.log("[Overlay] App ready"); - - mainWindow.on("closed", () => { - mainWindow = null; - }); - - mainWindow.webContents.on("render-process-gone", (_event, details) => { - console.error("[Overlay] Render process gone:", details); - }); - - ipcMain.on("window-ready", () => { - console.log("Overlay window ready"); - }); - - ipcMain.on("overlay-visible", (_event, visible: boolean) => { - setOverlayWindowVisible(Boolean(visible)); - }); - - ipcMain.handle("get-daemon-state", () => { - return ipcClient?.state || { status: "idle" }; - }); - - ipcMain.handle("get-connection-status", () => { - return ipcClient?.status || "disconnected"; - }); - - setupIPCClient(); -}); - -app.on("child-process-gone", (_event, details) => { - console.error("[Overlay] Child process gone:", details); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); - -app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createOverlayWindow(); - } -}); - -app.on("before-quit", () => { - if (mainWindow !== null && !mainWindow.isDestroyed()) { - mainWindow.close(); - } -}); diff --git a/overlay/src/preload.ts b/overlay/src/preload.ts index b4fd8d3..7e4b3c9 100644 --- a/overlay/src/preload.ts +++ b/overlay/src/preload.ts @@ -3,7 +3,7 @@ import type { AudioLevelMessage, ConnectionStatus, DaemonState, -} from "./ipc-client"; +} from "./shared/ipc-types"; const electronAPI = { onToggleListening: (callback: () => void): (() => void) => { diff --git a/overlay/src/renderer/useDaemonState.ts b/overlay/src/renderer/useDaemonState.ts index 3de411b..6653773 100644 --- a/overlay/src/renderer/useDaemonState.ts +++ b/overlay/src/renderer/useDaemonState.ts @@ -4,7 +4,7 @@ import type { ConnectionStatus, DaemonState, DaemonStatus, -} from "../ipc-client"; +} from "../shared/ipc-types"; export type OverlayState = | "hidden" diff --git a/src/app/command-server.ts b/src/app/command-server.ts new file mode 100644 index 0000000..02df4a9 --- /dev/null +++ b/src/app/command-server.ts @@ -0,0 +1,129 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { + createConnection, + createServer, + type Server, + type Socket, +} from "node:net"; +import { logger } from "../utils/logger"; +import { SOCKET_PATH } from "../utils/socket-path"; + +// The overlay no longer talks over this socket — daemon state reaches the +// renderer via webContents.send. What remains is a command channel for CLI +// verbs that need a payload (SIGUSR1 carries none): today only soniox-toggle. +// Binding the socket also doubles as the single-instance guard, and its +// existence is how `hyprvox soniox-toggle` decides whether the app is up. +export class CommandServer { + private server: Server | null = null; + + constructor(private readonly onAction: (action: string) => void) {} + + get socketPath(): string { + return SOCKET_PATH; + } + + private async checkAndCleanStaleSocket(): Promise { + if (!existsSync(SOCKET_PATH)) { + return false; + } + + return new Promise((resolve) => { + const testClient = createConnection({ path: SOCKET_PATH }); + const timeout = setTimeout(() => { + testClient.destroy(); + this.cleanupSocketFile(); + resolve(true); + }, 1000); + + testClient.on("connect", () => { + clearTimeout(timeout); + testClient.destroy(); + resolve(false); + }); + + testClient.on("error", () => { + clearTimeout(timeout); + testClient.destroy(); + this.cleanupSocketFile(); + resolve(true); + }); + }); + } + + private cleanupSocketFile(): void { + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + logger.debug({ path: SOCKET_PATH }, "Cleaned up stale socket file"); + } + } catch (err) { + logger.warn({ err, path: SOCKET_PATH }, "Failed to cleanup socket file"); + } + } + + async start(): Promise { + const wasStale = await this.checkAndCleanStaleSocket(); + + if (existsSync(SOCKET_PATH) && !wasStale) { + throw new Error("Another hyprvox instance is already running"); + } + + return new Promise((resolve, reject) => { + this.server = createServer((socket) => { + this.handleConnection(socket); + }); + + this.server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject(new Error("Socket address already in use")); + } else { + logger.warn({ err }, "Command socket error"); + reject(err); + } + }); + + this.server.listen(SOCKET_PATH, () => { + logger.info({ path: SOCKET_PATH }, "Command socket listening"); + resolve(); + }); + }); + } + + async stop(): Promise { + if (!this.server) { + return; + } + return new Promise((resolve) => { + this.server!.close(() => { + this.cleanupSocketFile(); + logger.info("Command socket stopped"); + this.server = null; + resolve(); + }); + }); + } + + private handleConnection(socket: Socket): void { + socket.on("data", (data) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); + for (const line of lines) { + try { + const msg = JSON.parse(line); + if (msg.type === "action" && typeof msg.action === "string") { + logger.debug({ action: msg.action }, "Command received"); + this.onAction(msg.action); + } + } catch { + // Malformed JSON - ignore + } + } + }); + + socket.on("error", (err) => { + logger.debug({ err }, "Command socket client error"); + }); + } +} diff --git a/src/app/main.ts b/src/app/main.ts index b762e3f..5eee063 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -1,11 +1,8 @@ -// Phase 0 spike: single-process topology. -// -// One Electron process hosts BOTH the daemon (DaemonService) and the overlay -// window, replacing the systemd -> supervisor -> daemon -> overlay-child stack. -// For this spike the daemon still emits state over its unix socket and the -// window's IPCClient connects to it in-process (loopback); Phase 1 replaces -// that hop with a direct webContents.send. The point here is only to prove the -// two halves boot and run inside a single process. +// Single-app topology: one Electron process hosts the daemon (DaemonService) +// and the overlay window. Daemon state flows main -> renderer directly via +// webContents.send — there is no loopback socket and no supervision stack; +// Electron is the single supervisor (ADR-0003). Launched via Hyprland +// exec-once (`hyprvox start`) or lazily by `hyprvox toggle`. import * as path from "node:path"; import { app, @@ -15,13 +12,33 @@ import { screen, } from "electron"; import { DaemonService } from "../daemon/service"; -import { type DaemonState, getIPCClient, type IPCClient } from "../../overlay/src/ipc-client"; +import type { + AudioLevelMessage, + ConnectionStatus, + DaemonState, +} from "../shared/ipc-types"; +import { getBundledOverlayPath } from "../utils/project-paths"; +import { SOCKET_PATH } from "../utils/socket-path"; +import { CommandServer } from "./command-server"; if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { console.error("[App] No display environment (DISPLAY or WAYLAND_DISPLAY)"); process.exit(1); } +// The overlay parks itself off-screen by moving its own window (ADR-0001), +// which only works as an XWayland client — native Wayland windows cannot +// self-position, setPosition becomes a silent no-op, and the overlay freezes +// wherever the compositor mapped it. This switch pins X11 when no ozone env +// hint is present. It is NOT sufficient on its own: Electron consumes +// ELECTRON_OZONE_PLATFORM_HINT in C++ before this file runs, and (verified on +// Electron 34) hint=auto alongside --ozone-platform=x11 half-initializes the +// browser and no window is ever created. The CLI launcher therefore strips +// that env var from the spawn environment (src/cli/app-launcher.ts); anyone +// exec'ing the electron binary directly must do the same. +app.commandLine.appendSwitch("ozone-platform", "x11"); + +// Disable GPU acceleration to prevent rendering crashes on Wayland. app.disableHardwareAcceleration(); app.commandLine.appendSwitch("disable-gpu"); app.commandLine.appendSwitch("disable-software-rasterizer"); @@ -30,13 +47,14 @@ app.commandLine.appendSwitch("disable-dev-shm-usage"); // Where the built renderer + preload live (overlay project's tsc/vite output). const OVERLAY_DIST = process.env.HYPRVOX_OVERLAY_DIST || - path.join(app.getAppPath(), "overlay", "dist"); + path.join(getBundledOverlayPath(), "dist"); const OVERLAY_CONFIG = { width: 400, height: 60, marginBottom: 80 }; let mainWindow: BrowserWindow | null = null; -let ipcClient: IPCClient | null = null; let service: DaemonService | null = null; +let commandServer: CommandServer | null = null; +let lastState: DaemonState = { status: "idle" }; let restingPosition = { x: 0, y: 0 }; let parkedPosition = { x: 0, y: 0 }; let overlayVisible = false; @@ -45,16 +63,25 @@ process.on("uncaughtException", (err) => console.error("[App] uncaught:", err)); process.on("unhandledRejection", (r) => console.error("[App] unhandled:", r)); // Without these, a SIGTERM/SIGINT kills Electron before service.stop() runs, -// leaving a stale socket file that fails the next boot with EADDRINUSE. +// leaving a stale pidfile/socket that fails the next boot. process.on("SIGTERM", () => app.quit()); process.on("SIGINT", () => app.quit()); +function sendToRenderer(channel: string, payload: unknown): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, payload); + } +} + function createOverlayWindow(): BrowserWindow { const display = screen.getPrimaryDisplay(); const { width: sw, height: sh } = display.workArea; const x = Math.floor((sw - OVERLAY_CONFIG.width) / 2); const y = sh - OVERLAY_CONFIG.height - OVERLAY_CONFIG.marginBottom; + // Resting spot (visible) and a parked spot fully below the monitor. The + // window stays mapped (ADR-0001); when idle it moves off-screen so the + // compositor has no transparent rect to blur into a "ghost pill". restingPosition = { x, y }; parkedPosition = { x, @@ -88,9 +115,64 @@ function createOverlayWindow(): BrowserWindow { window.setAlwaysOnTop(true, "floating"); window.setIgnoreMouseEvents(true, { forward: true }); window.loadFile(path.join(OVERLAY_DIST, "renderer", "index.html")); + + // A crashed or hung renderer only loses the window; the daemon half keeps + // running and the recreated window re-syncs from lastState. + window.on("unresponsive", () => { + console.error("[App] Overlay window unresponsive, recreating"); + recreateOverlayWindow(); + }); + window.webContents.on("render-process-gone", (_event, details) => { + console.error("[App] Renderer process gone:", details); + recreateOverlayWindow(); + }); + window.webContents.on("preload-error", (_event, preloadPath, error) => { + console.error("[App] Preload failed:", preloadPath, error); + }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL) => { + console.error( + "[App] Renderer failed to load:", + errorCode, + errorDescription, + validatedURL, + ); + }, + ); + window.webContents.on( + "console-message", + (_event, level, message, line, sourceId) => { + if (level >= 2) { + console.error(`[Renderer:${level}] ${message} (${sourceId}:${line})`); + } + }, + ); + window.webContents.on("did-finish-load", () => { + // In-process daemon: the renderer's "connection" to it is the process + // itself being alive, so it is connected by definition. + sendToRenderer("connection-status", "connected" satisfies ConnectionStatus); + sendToRenderer("daemon-state", lastState); + }); + return window; } +function recreateOverlayWindow(): void { + if (mainWindow) { + mainWindow.destroy(); + mainWindow = null; + } + setTimeout(() => { + if (!mainWindow) { + mainWindow = createOverlayWindow(); + mainWindow.on("closed", () => { + mainWindow = null; + }); + } + }, 2000); +} + function setOverlayWindowVisible(visible: boolean): void { if (!mainWindow || mainWindow.isDestroyed() || overlayVisible === visible) { return; @@ -100,54 +182,51 @@ function setOverlayWindowVisible(visible: boolean): void { mainWindow.setPosition(target.x, target.y); } -function setupIPCClient(): void { - ipcClient = getIPCClient(); - ipcClient.on("stateChange", (state: DaemonState) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("daemon-state", state); - } - }); - ipcClient.on("connectionStatusChange", (status) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("connection-status", status); - } - }); - ipcClient.on("audioLevel", (audioLevel) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("audio-level", audioLevel); - } - }); - ipcClient.on("connected", () => console.log("[App] IPC connected (in-process)")); - ipcClient.on("error", (err: Error) => console.error("[App] IPC error:", err.message)); - ipcClient.connect(); -} - async function boot(): Promise { - // 1. Start the daemon in-process. It binds its socket, installs its own - // SIGUSR1 handler (so `hyprvox toggle` drives THIS process), and — with - // HYPRVOX_EMBEDDED_OVERLAY set — does NOT spawn a child overlay. + // Resolved paths, not env: a boot line must identify the run it came from + // unambiguously (HOME remapping and env overrides both move these). console.log( - "[App] boot: pid=%d socket=%s pidfile=%s embedded=%s", + "[App] boot: pid=%d socket=%s pidfile=%s", process.pid, - process.env.HYPRVOX_SOCKET_PATH || "(default)", - process.env.HYPRVOX_PID_FILE || "(default)", - process.env.HYPRVOX_EMBEDDED_OVERLAY || "(unset)", + SOCKET_PATH, + process.env.HYPRVOX_PID_FILE || "(default daemon.pid)", ); + + // 1. Bind the command socket first: it is the single-instance guard, so a + // second launch dies here before touching the pidfile or the recorder. + commandServer = new CommandServer((action) => { + if (action === "soniox-toggle") { + service?.triggerSonioxToggle(); + } + }); + await commandServer.start(); + + // 2. Start the daemon in-process. It writes the pidfile and installs its + // own SIGUSR1 handler, so `hyprvox toggle` drives THIS process. service = new DaemonService(); + service.on("state", (state: DaemonState) => { + lastState = state; + sendToRenderer("daemon-state", state); + }); + service.on("audioLevel", (audioLevel: AudioLevelMessage) => { + sendToRenderer("audio-level", audioLevel); + }); await service.start(); console.log("[App] DaemonService started in-process, pid", process.pid); - // 2. Bring up the window and connect to the daemon's socket in-process. + // 3. Bring up the window. + ipcMain.on("overlay-visible", (_e: unknown, visible: boolean) => + setOverlayWindowVisible(Boolean(visible)), + ); + ipcMain.handle("get-daemon-state", () => lastState); + ipcMain.handle( + "get-connection-status", + (): ConnectionStatus => (service ? "connected" : "disconnected"), + ); mainWindow = createOverlayWindow(); mainWindow.on("closed", () => { mainWindow = null; }); - ipcMain.on("overlay-visible", (_e: unknown, visible: boolean) => - setOverlayWindowVisible(Boolean(visible)), - ); - ipcMain.handle("get-daemon-state", () => ipcClient?.state || { status: "idle" }); - ipcMain.handle("get-connection-status", () => ipcClient?.status || "disconnected"); - setupIPCClient(); console.log("[App] App ready (single process)"); } @@ -162,13 +241,14 @@ app.whenReady().then(() => let stopping = false; app.on("will-quit", (event: { preventDefault: () => void }) => { - if (stopping || !service) { + if (stopping || (!service && !commandServer)) { return; } stopping = true; event.preventDefault(); - service - .stop() + Promise.resolve() + .then(() => service?.stop()) + .then(() => commandServer?.stop()) .catch((err) => console.error("[App] Shutdown error:", err)) .finally(() => app.exit(0)); }); diff --git a/src/daemon/ipc.ts b/src/daemon/ipc.ts deleted file mode 100644 index 2a949e8..0000000 --- a/src/daemon/ipc.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { EventEmitter } from "node:events"; -import { existsSync, unlinkSync } from "node:fs"; -import { - createConnection, - createServer, - type Server, - type Socket, -} from "node:net"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { DaemonStatus, IPCMessage } from "../shared/ipc-types"; -import { logger } from "../utils/logger"; - -const IPC_PROTOCOL_VERSION = 1; -export const SOCKET_PATH = - process.env.HYPRVOX_SOCKET_PATH || - join(homedir(), ".config", "hypr", "vox", "daemon.sock"); - -export type { DaemonStatus, IPCMessage, ActionMessage } from "../shared/ipc-types"; - -export interface IPCServerEvents { - clientConnected: (clientId: number) => void; - clientDisconnected: (clientId: number) => void; - error: (error: Error) => void; - command: (action: string) => void; -} - -type StateIPCMessage = Extract; - -export class IPCServer extends EventEmitter { - private server: Server | null = null; - private clients: Map = new Map(); - private clientIdCounter = 0; - private currentState: StateIPCMessage | null = null; - - get socketPath(): string { - return SOCKET_PATH; - } - - get clientCount(): number { - return this.clients.size; - } - - private async checkAndCleanStaleSocket(): Promise { - if (!existsSync(SOCKET_PATH)) { - return false; - } - - return new Promise((resolve) => { - const testClient = createConnection({ path: SOCKET_PATH }); - const timeout = setTimeout(() => { - testClient.destroy(); - this.cleanupSocketFile(); - resolve(true); - }, 1000); - - testClient.on("connect", () => { - clearTimeout(timeout); - testClient.destroy(); - resolve(false); - }); - - testClient.on("error", (err: NodeJS.ErrnoException) => { - clearTimeout(timeout); - testClient.destroy(); - if (err.code === "ECONNREFUSED" || err.code === "ENOENT") { - this.cleanupSocketFile(); - resolve(true); - } else { - this.cleanupSocketFile(); - resolve(true); - } - }); - }); - } - - private cleanupSocketFile(): void { - try { - if (existsSync(SOCKET_PATH)) { - unlinkSync(SOCKET_PATH); - logger.debug({ path: SOCKET_PATH }, "Cleaned up stale socket file"); - } - } catch (err) { - logger.warn({ err, path: SOCKET_PATH }, "Failed to cleanup socket file"); - } - } - - async start(): Promise { - const wasStale = await this.checkAndCleanStaleSocket(); - - if (existsSync(SOCKET_PATH) && !wasStale) { - throw new Error("Another daemon instance is already running"); - } - - return new Promise((resolve, reject) => { - this.server = createServer((socket) => { - this.handleClientConnection(socket); - }); - - this.server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - reject(new Error("Socket address already in use")); - } else { - this.emit("error", err); - reject(err); - } - }); - - this.server.listen(SOCKET_PATH, () => { - logger.info({ path: SOCKET_PATH }, "IPC server started"); - resolve(); - }); - }); - } - - async stop(): Promise { - for (const [clientId, socket] of this.clients) { - socket.destroy(); - logger.debug({ clientId }, "Closed client connection"); - } - this.clients.clear(); - - if (this.server) { - return new Promise((resolve) => { - this.server!.close(() => { - this.cleanupSocketFile(); - logger.info("IPC server stopped"); - this.server = null; - resolve(); - }); - }); - } - } - - private handleClientConnection(socket: Socket): void { - const clientId = ++this.clientIdCounter; - this.clients.set(clientId, socket); - - logger.debug({ clientId }, "IPC client connected"); - this.emit("clientConnected", clientId); - - const helloMessage: StateIPCMessage = { - type: "hello", - version: IPC_PROTOCOL_VERSION, - status: this.currentState?.status || "idle", - timestamp: Date.now(), - }; - this.sendToClient(clientId, helloMessage); - - socket.on("data", (data) => { - try { - const lines = data - .toString() - .split("\n") - .filter((l) => l.trim()); - for (const line of lines) { - const msg = JSON.parse(line); - logger.debug({ clientId, msg }, "Received message from client"); - if (msg.type === "action" && typeof msg.action === "string") { - this.emit("command", msg.action); - } - } - } catch { - // Malformed JSON - ignore - } - }); - - socket.on("close", () => { - this.clients.delete(clientId); - logger.debug({ clientId }, "IPC client disconnected"); - this.emit("clientDisconnected", clientId); - }); - - socket.on("error", (err) => { - logger.warn({ clientId, err }, "IPC client error"); - this.clients.delete(clientId); - }); - } - - private sendToClient(clientId: number, message: IPCMessage): boolean { - const socket = this.clients.get(clientId); - if (!socket || socket.destroyed) { - this.clients.delete(clientId); - return false; - } - - try { - socket.write(JSON.stringify(message) + "\n"); - return true; - } catch (err) { - logger.warn({ clientId, err }, "Failed to send message to client"); - this.clients.delete(clientId); - return false; - } - } - - broadcast(message: StateIPCMessage): void { - this.currentState = message; - - const stateMessage: IPCMessage = { - type: "state", - status: message.status, - timestamp: message.timestamp, - lastTranscription: message.lastTranscription, - error: message.error, - }; - - let successCount = 0; - for (const clientId of this.clients.keys()) { - if (this.sendToClient(clientId, stateMessage)) { - successCount++; - } - } - - if (this.clients.size > 0) { - logger.debug( - { status: message.status, clients: successCount }, - "Broadcast state to clients", - ); - } - } - - broadcastStatus( - status: DaemonStatus, - extra?: { lastTranscription?: string; error?: string; timestamp?: number }, - ): void { - const timestamp = extra?.timestamp ?? Date.now(); - this.broadcast({ - type: "state", - status, - timestamp, - lastTranscription: extra?.lastTranscription, - error: extra?.error, - }); - logger.debug( - { status, timestamp, clients: this.clients.size }, - "State broadcast", - ); - } - - broadcastAudioLevel( - level: number, - peak?: number, - timestamp = Date.now(), - ): void { - if (this.clients.size === 0) { - return; - } - - const message: IPCMessage = { - type: "audio_level", - level, - peak, - timestamp, - }; - - for (const clientId of this.clients.keys()) { - this.sendToClient(clientId, message); - } - } -} - -let ipcServerInstance: IPCServer | null = null; - -export function getIPCServer(): IPCServer { - if (!ipcServerInstance) { - ipcServerInstance = new IPCServer(); - } - return ipcServerInstance; -} - -export function resetIPCServer(): void { - ipcServerInstance = null; -} diff --git a/src/daemon/overlay-process.ts b/src/daemon/overlay-process.ts deleted file mode 100644 index 825ff35..0000000 --- a/src/daemon/overlay-process.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, - unlinkSync, -} from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { Config } from "../config/schema"; -import { logError, logger } from "../utils/logger"; -import { getBundledOverlayPath } from "../utils/project-paths"; - -type OverlayTrigger = "startup" | "restart"; - -export class OverlayProcessManager { - private static readonly RESTART_INITIAL_DELAY_MS = 250; - private static readonly RESTART_MAX_DELAY_MS = 2000; - private static readonly RESTART_WINDOW_MS = 60000; - private static readonly RESTART_MAX_ATTEMPTS = 5; - - private config: Config; - private process?: ChildProcess; - private restartTimer?: NodeJS.Timeout; - private stopRequested = false; - private restartAttempts: number[] = []; - - public constructor( - config: Config, - private readonly pidFile: string, - private logFile: string, - ) { - this.config = config; - } - - public updateConfig(config: Config): void { - this.config = config; - this.logFile = join(config.paths.logs, "overlay.log"); - } - - public start(trigger: OverlayTrigger = "startup"): void { - // When the daemon runs inside the Electron main process (single-app - // topology), the overlay is the host — do not spawn a second Electron. - if (process.env.HYPRVOX_EMBEDDED_OVERLAY) { - return; - } - if (!this.config.overlay?.enabled || !this.config.overlay?.autoStart) { - return; - } - - if ( - this.process && - this.process.exitCode === null && - this.process.signalCode === null - ) { - return; - } - - this.stopRequested = false; - - (async () => { - try { - const raw = readFileSync(this.pidFile, "utf8").trim(); - const oldPid = parseInt(raw, 10); - if (!Number.isNaN(oldPid)) { - process.kill(oldPid, "SIGTERM"); - logger.debug({ oldPid }, "Terminated stale overlay process"); - await this.waitForProcessExit(oldPid); - } - } catch { - // PID file absent or process already dead. - } - - const overlayPath = this.getOverlayPath(); - if (!existsSync(overlayPath)) { - logger.warn( - { path: overlayPath }, - "Overlay not found, skipping auto-start", - ); - return; - } - - try { - this.spawnOverlay(overlayPath, trigger); - } catch (error) { - logError("Failed to start overlay", error); - this.scheduleRestart("start_failure"); - } - })(); - } - - public stop(): void { - this.stopRequested = true; - if (this.restartTimer) { - clearTimeout(this.restartTimer); - this.restartTimer = undefined; - } - - if (this.process) { - try { - this.process.kill("SIGTERM"); - } catch (e) { - logger.debug({ err: e }, "Failed to kill overlay process"); - } - this.process = undefined; - } - - try { - const raw = readFileSync(this.pidFile, "utf8").trim(); - const oldPid = parseInt(raw, 10); - if (!Number.isNaN(oldPid)) { - process.kill(oldPid, "SIGTERM"); - logger.debug( - { oldPid }, - "Terminated stale overlay from previous session", - ); - } - } catch { - // PID file absent or process already dead. - } - - this.removePidFile(); - } - - private getOverlayPath(): string { - if (this.config.overlay?.binaryPath) { - return this.config.overlay.binaryPath; - } - return getBundledOverlayPath(); - } - - private resolveLaunchCommand(overlayPath: string): { - command: string; - args: string[]; - mode: "electron_direct" | "bun_fallback"; - } { - const directElectronPath = join( - overlayPath, - "node_modules", - ".bin", - "electron", - ); - if (existsSync(directElectronPath)) { - return { - command: directElectronPath, - args: ["."], - mode: "electron_direct", - }; - } - - return { - command: "bun", - args: ["run", "start"], - mode: "bun_fallback", - }; - } - - private spawnOverlay(overlayPath: string, trigger: OverlayTrigger): void { - const launch = this.resolveLaunchCommand(overlayPath); - const overlayEnv = this.buildOverlayEnv(); - - logger.debug( - { - WAYLAND_DISPLAY: overlayEnv.WAYLAND_DISPLAY, - DISPLAY: overlayEnv.DISPLAY, - XDG_RUNTIME_DIR: overlayEnv.XDG_RUNTIME_DIR, - }, - "Starting overlay with display environment", - ); - - mkdirSync(this.config.paths.logs, { recursive: true, mode: 0o700 }); - const overlayLogFd = openSync(this.logFile, "a"); - - this.process = spawn(launch.command, launch.args, { - cwd: overlayPath, - detached: true, - stdio: ["ignore", overlayLogFd, overlayLogFd], - env: overlayEnv, - }); - - this.process.on("error", (err) => { - logger.warn({ err }, "Overlay process error"); - this.scheduleRestart("spawn_error"); - }); - - const overlayPid = this.process.pid; - this.process.on("exit", (code, signal) => { - if (this.process?.pid === overlayPid) { - this.process = undefined; - } - this.removePidFile(overlayPid); - logger.warn( - { - pid: overlayPid, - code, - signal, - stopRequested: this.stopRequested, - logFile: this.logFile, - }, - "Overlay process exited", - ); - if (!this.stopRequested) { - this.scheduleRestart("process_exit"); - } - }); - - this.process.unref(); - - const pid = this.process.pid; - if (pid) { - writeFile(this.pidFile, pid.toString()).catch((e) => { - logger.debug({ err: e }, "Failed to write overlay PID file"); - }); - } - - closeSync(overlayLogFd); - - if (trigger === "startup") { - this.restartAttempts = []; - } - - logger.info( - { - pid, - logFile: this.logFile, - trigger, - launchMode: launch.mode, - }, - "Overlay started", - ); - } - - private buildOverlayEnv(): NodeJS.ProcessEnv { - const uid = process.getuid?.() ?? 1000; - const overlayEnv: NodeJS.ProcessEnv = { ...process.env }; - - if (!overlayEnv.WAYLAND_DISPLAY && !overlayEnv.DISPLAY) { - overlayEnv.WAYLAND_DISPLAY = "wayland-1"; - overlayEnv.DISPLAY = ":0"; - } - - if (!overlayEnv.XDG_RUNTIME_DIR) { - overlayEnv.XDG_RUNTIME_DIR = `/run/user/${uid}`; - } - - return overlayEnv; - } - - private scheduleRestart(reason: string): void { - if ( - this.stopRequested || - this.restartTimer || - !this.config.overlay?.enabled || - !this.config.overlay?.autoStart - ) { - return; - } - - const now = Date.now(); - this.pruneRestartAttempts(now); - if ( - this.restartAttempts.length >= OverlayProcessManager.RESTART_MAX_ATTEMPTS - ) { - logger.error( - { - reason, - attempts: this.restartAttempts.length, - windowMs: OverlayProcessManager.RESTART_WINDOW_MS, - logFile: this.logFile, - }, - "Overlay crashed too often; automatic restart disabled", - ); - return; - } - - this.restartAttempts.push(now); - const restartDelayMs = Math.min( - OverlayProcessManager.RESTART_INITIAL_DELAY_MS * - 2 ** (this.restartAttempts.length - 1), - OverlayProcessManager.RESTART_MAX_DELAY_MS, - ); - - logger.warn( - { - reason, - delayMs: restartDelayMs, - attempt: this.restartAttempts.length, - logFile: this.logFile, - }, - "Scheduling overlay restart", - ); - - this.restartTimer = setTimeout(() => { - this.restartTimer = undefined; - this.start("restart"); - }, restartDelayMs); - } - - private pruneRestartAttempts(now = Date.now()): void { - this.restartAttempts = this.restartAttempts.filter( - (at) => now - at <= OverlayProcessManager.RESTART_WINDOW_MS, - ); - } - - private removePidFile(expectedPid?: number): void { - try { - if (expectedPid !== undefined) { - const raw = readFileSync(this.pidFile, "utf8").trim(); - const currentPid = parseInt(raw, 10); - if (currentPid !== expectedPid) { - return; - } - } - unlinkSync(this.pidFile); - } catch (e) { - logger.debug({ err: e }, "Failed to remove overlay PID file"); - } - } - - private async waitForProcessExit( - pid: number, - timeoutMs = 3000, - ): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - process.kill(pid, 0); - await new Promise((r) => setTimeout(r, 50)); - } catch { - return; - } - } - logger.debug({ pid, timeoutMs }, "Timeout waiting for process exit"); - } -} diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 7fe1234..16ef1b6 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { unlinkSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; @@ -15,7 +16,11 @@ import { ClipboardAccessError, ClipboardManager } from "../output/clipboard"; import { DesktopTextTyper, LiveDictationWriter } from "../output/live-dictation"; import { formatSonioxWithLLM } from "../transcribe/soniox-streaming"; import { notify } from "../output/notification"; -import type { DaemonStatus } from "../shared/ipc-types"; +import type { + AudioLevelMessage, + DaemonState as DaemonStateMessage, + DaemonStatus, +} from "../shared/ipc-types"; import { appendStatsAggregateEntry } from "../stats/aggregate"; import { DeepgramTranscriber } from "../transcribe/deepgram"; import type { @@ -39,14 +44,13 @@ import { ErrorTemplates, formatUserError } from "../utils/error-templates"; import { errorIncludes, getErrorCode } from "../utils/errors"; import { appendHistory } from "../utils/history"; import { logError, logger } from "../utils/logger"; +import { projectRoot } from "../utils/project-paths"; import { incrementTranscriptionCount, loadStats } from "../utils/stats"; import { shouldCompressAudio } from "./audio-strategy"; import { checkHotkeyConflict } from "./conflict"; import { saveDebugAudioCapture } from "./debug-audio"; import { runGroqTranscriptionWithLiveSession } from "./groq-transcription"; import { HotkeyListener } from "./hotkey"; -import { getIPCServer, type IPCServer } from "./ipc"; -import { OverlayProcessManager } from "./overlay-process"; import { attachLiveDictationTranscriptHandler, attachStreamingPcmHandler, @@ -62,7 +66,6 @@ import { } from "./transcription-metrics"; const HALLUCINATION_MAX_CHARS = 50; -const projectRoot = join(import.meta.dir, "..", ".."); // Common Whisper hallucination patterns (from YouTube training data) const HALLUCINATION_PATTERNS = [ @@ -97,7 +100,7 @@ export interface DaemonState { lastError?: string; } -export class DaemonService { +export class DaemonService extends EventEmitter { private static readonly OVERLAY_AUDIO_LEVEL_INTERVAL_MS = 33; private status: DaemonStatus = "idle"; private config: Config; @@ -126,16 +129,15 @@ export class DaemonService { private reloadSignalHandler: () => void; private keepAliveInterval?: NodeJS.Timeout; private cancelPending = false; - private ipcServer: IPCServer; private stateWriteDebounceTimer?: NodeJS.Timeout; private pendingStateWrite = false; - private overlay: OverlayProcessManager; private lastOverlayAudioLevelAt = 0; private smoothedOverlayLevel = 0; private contextLexicon: string[] = []; private providerBoostWords: string[] = []; constructor() { + super(); this.config = configService.get(); this.recorder = new AudioRecorder(); this.hotkeyListener = new HotkeyListener(); @@ -144,15 +146,9 @@ export class DaemonService { this.merger = new TranscriptMerger(); this.refreshContextLexicon(); this.clipboard = new ClipboardManager(); - this.ipcServer = getIPCServer(); const configDir = join(homedir(), ".config", "hypr", "vox"); this.pidFile = process.env.HYPRVOX_PID_FILE || join(configDir, "daemon.pid"); this.stateFile = join(configDir, "daemon.state"); - this.overlay = new OverlayProcessManager( - this.config, - join(configDir, "overlay.pid"), - join(this.config.paths.logs, "overlay.log"), - ); const stats = loadStats(); this.transcriptionCountToday = stats.today; @@ -171,7 +167,6 @@ export class DaemonService { this.groq.reset(); this.deepgram.reset(); this.merger.reset(); - this.overlay.updateConfig(this.config); this.refreshContextLexicon(); logger.info("Config reloaded successfully"); notify("Config Reloaded", "Configuration updated", "info"); @@ -267,12 +262,22 @@ export class DaemonService { } private updateState(): void { - this.ipcServer.broadcastStatus(this.status, { + this.emit("state", this.getStateMessage()); + this.scheduleStateWrite(); + } + + /** + * Snapshot of the state the overlay renders. Emitted as the "state" event + * on every status change; also served to the renderer's get-daemon-state + * request when the window (re)loads after a state was already emitted. + */ + public getStateMessage(): DaemonStateMessage { + return { + status: this.status, lastTranscription: this.lastTranscription?.toISOString(), error: this.lastError, timestamp: Date.now(), - }); - this.scheduleStateWrite(); + }; } private handleRecorderLevel(payload: AudioLevelPayload): void { @@ -280,7 +285,7 @@ export class DaemonService { return; } - if (this.ipcServer.clientCount === 0) { + if (this.listenerCount("audioLevel") === 0) { return; } @@ -295,11 +300,13 @@ export class DaemonService { this.smoothedOverlayLevel = this.smoothedOverlayLevel * 0.7 + payload.level * 0.3; - this.ipcServer.broadcastAudioLevel( - Math.min(1, this.smoothedOverlayLevel), - payload.peak, - payload.timestamp, - ); + const message: AudioLevelMessage = { + type: "audio_level", + level: Math.min(1, this.smoothedOverlayLevel), + peak: payload.peak, + timestamp: payload.timestamp, + }; + this.emit("audioLevel", message); } private setStatus(status: DaemonStatus, error?: string) { @@ -333,15 +340,18 @@ export class DaemonService { notify(title, message, type); } + /** + * Entry point for the CLI's `soniox-toggle` verb, delivered by the app's + * command socket (SIGUSR1 carries no payload, so this cannot be a signal). + */ + public triggerSonioxToggle(): void { + logger.info("Received soniox-toggle command via IPC"); + this.handleSonioxTrigger(); + } + private setupListeners() { this.hotkeyListener.on("trigger", () => this.handleTrigger()); this.hotkeyListener.on("soniox-trigger", () => this.handleSonioxTrigger()); - this.ipcServer.on("command", (action: string) => { - if (action === "soniox-toggle") { - logger.info("Received soniox-toggle command via IPC"); - this.handleSonioxTrigger(); - } - }); this.recorder.on("start", () => { this.lastOverlayAudioLevelAt = 0; @@ -423,9 +433,7 @@ export class DaemonService { try { assertAudioBackendAvailable(); await writeFile(this.pidFile, process.pid.toString()); - await this.ipcServer.start(); this.updateState(); - this.overlay.start(); const defaultHotkeyDisabled = this.config.behavior.hotkey.toLowerCase() === "disabled"; @@ -470,7 +478,6 @@ export class DaemonService { public async stop() { this.hotkeyListener.stop(); await this.recorder.stop(true); - this.overlay.stop(); process.off("SIGUSR1", this.signalHandler); process.off("SIGUSR2", this.reloadSignalHandler); if (this.keepAliveInterval) { @@ -480,7 +487,6 @@ export class DaemonService { clearTimeout(this.stateWriteDebounceTimer); } await this.teardownStreaming("Failed to stop streaming during shutdown"); - await this.ipcServer.stop(); for (const file of [this.pidFile, this.stateFile]) { try { unlinkSync(file); diff --git a/src/daemon/supervisor.ts b/src/daemon/supervisor.ts deleted file mode 100644 index b9ddff2..0000000 --- a/src/daemon/supervisor.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { unlink, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { notify } from "../output/notification"; -import { readJsonFile } from "../utils/file-ops"; -import { logger } from "../utils/logger"; - -interface DaemonState { - status: string; - lastError?: string; - [key: string]: unknown; -} - -export class DaemonSupervisor { - private restartCount = 0; - private firstRestartTime = 0; - private readonly MAX_RESTARTS = 3; - private readonly WINDOW_MS = 5 * 60 * 1000; - private isStopping = false; - - constructor(private scriptPath: string) {} - - public start() { - this.spawnDaemon(); - } - - public stop() { - this.isStopping = true; - } - - private spawnDaemon() { - if (this.isStopping) return; - - logger.info("Supervisor: Spawning daemon process..."); - - const child = spawn( - "bun", - ["run", this.scriptPath, "start", "--daemon-worker"], - { - stdio: "inherit", - env: { ...process.env, HYPRVOX_DAEMON_WORKER: "true" }, - }, - ); - - child.on("exit", (code, signal) => { - if (this.isStopping || code === 0) { - logger.info( - `Supervisor: Daemon exited cleanly (code: ${code}, signal: ${signal})`, - ); - return; - } - - logger.error( - `Supervisor: Daemon crashed (code: ${code}, signal: ${signal})`, - ); - this.handleCrash(); - }); - } - - private handleCrash() { - const now = Date.now(); - - if (now - this.firstRestartTime > this.WINDOW_MS) { - this.restartCount = 1; - this.firstRestartTime = now; - } else { - this.restartCount++; - } - - if (this.restartCount > this.MAX_RESTARTS) { - this.handleFatalCrash(); - return; - } - - logger.warn( - `Supervisor: Restarting daemon (${this.restartCount}/${this.MAX_RESTARTS})...`, - ); - setTimeout(() => this.spawnDaemon(), 1000); - } - - private async handleFatalCrash() { - const msg = `Daemon crashed ${this.MAX_RESTARTS} times in 5 minutes. Stopping.`; - logger.error(msg); - - const configDir = join(homedir(), ".config", "hypr", "vox"); - const stateFile = join(configDir, "daemon.state"); - const pidFile = join(configDir, "daemon.pid"); - - if (existsSync(stateFile)) { - try { - const state = await readJsonFile(stateFile); - if (state) { - state.status = "error"; - state.lastError = msg; - await writeFile(stateFile, JSON.stringify(state, null, 2)); - } - } catch (e) { - logger.error({ error: e }, "Failed to write crash state"); - } - } - - if (existsSync(pidFile)) { - try { - await unlink(pidFile); - } catch (e) { - // PID file may already be deleted - logger.debug( - { err: e }, - "Failed to remove PID file during fatal crash handling", - ); - } - } - - notify("Daemon Critical Failure", msg, "error"); - process.exit(1); - } -} diff --git a/tests/integration/crash_recovery.test.ts b/tests/integration/crash_recovery.test.ts deleted file mode 100644 index 7aea494..0000000 --- a/tests/integration/crash_recovery.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { execSync, spawn } from "node:child_process"; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; - -describe.skipIf(isCI)("Daemon Crash Recovery Integration", () => { - const testHome = join(tmpdir(), `hyprvox-crash-test-${Date.now()}`); - const configDir = join(testHome, ".config", "hypr", "vox"); - const pidFile = join(configDir, "daemon.pid"); - const configFile = join(configDir, "config.json"); - const stateFile = join(configDir, "daemon.state"); - - beforeEach(() => { - if (existsSync(testHome)) { - rmSync(testHome, { recursive: true, force: true }); - } - mkdirSync(configDir, { recursive: true }); - - const testConfig = { - apiKeys: { - groq: "gsk_test_12345678901234567890", - deepgram: "00000000-0000-0000-0000-000000000000", - }, - behavior: { - hotkey: "RIGHT CONTROL", - audioDevice: "default", - clipboard: { append: true, minDuration: 0.6, maxDuration: 300 }, - }, - // Disable the overlay: this suite exercises the daemon, not the GUI. - // Leaving it enabled spawns a real Electron window on the dev's live - // Wayland session, and because the overlay is detached it survives the - // SIGKILLs below and leaks as an orphan. - overlay: { enabled: false, autoStart: false }, - paths: { - logs: join(configDir, "logs"), - history: join(configDir, "history.json"), - }, - transcription: { - language: "en", - boostWords: [], - }, - }; - writeFileSync(configFile, JSON.stringify(testConfig), { mode: 0o600 }); - }); - - afterEach(() => { - try { - execSync(`HOME=${testHome} bun run index.ts stop`, { - env: { ...process.env, HOME: testHome }, - stdio: "ignore", - }); - } catch (_e) {} - - if (existsSync(pidFile)) { - try { - const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); - process.kill(pid, "SIGKILL"); - } catch (_e) {} - } - - const overlayPidFile = join(configDir, "overlay.pid"); - if (existsSync(overlayPidFile)) { - try { - const overlayPid = parseInt( - readFileSync(overlayPidFile, "utf-8").trim(), - 10, - ); - // The overlay is spawned detached (its own process group), so a - // SIGKILL to the lone PID can orphan the child Electron processes. - // Kill the whole group; fall back to the single PID. - try { - process.kill(-overlayPid, "SIGKILL"); - } catch (_e) { - process.kill(overlayPid, "SIGKILL"); - } - } catch (_e) {} - } - - rmSync(testHome, { recursive: true, force: true }); - }); - - const getPid = () => { - if (!existsSync(pidFile)) return null; - try { - return parseInt(readFileSync(pidFile, "utf-8").trim(), 10); - } catch (_e) { - return null; - } - }; - - const waitForDaemon = async (timeout = 10000) => { - const start = Date.now(); - while (Date.now() - start < timeout) { - const pid = getPid(); - if (pid && pid > 0) return pid; - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error("Timeout waiting for daemon to start"); - }; - - it("should auto-restart after a crash", async () => { - const supervisorProcess = spawn("bun", ["run", "index.ts", "start"], { - env: { ...process.env, HOME: testHome }, - stdio: "pipe", - }); - - const initialPid = await waitForDaemon(); - expect(initialPid).toBeGreaterThan(0); - - process.kill(initialPid, "SIGKILL"); - - let newPid = 0; - const start = Date.now(); - while (Date.now() - start < 15000) { - const pid = getPid(); - if (pid && pid !== initialPid) { - newPid = pid; - break; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - expect( - newPid, - "Daemon should have restarted with a new PID", - ).toBeGreaterThan(0); - expect(newPid).not.toBe(initialPid); - - supervisorProcess.kill("SIGKILL"); - }, 30000); - - it("should stop after exceeding max restarts (3 crashes)", async () => { - const supervisorProcess = spawn("bun", ["run", "index.ts", "start"], { - env: { ...process.env, HOME: testHome }, - stdio: "pipe", - }); - - let currentPid = await waitForDaemon(); - const pids = [currentPid]; - - for (let i = 0; i < 3; i++) { - const pidToKill = currentPid; - process.kill(pidToKill, "SIGKILL"); - - const start = Date.now(); - let restarted = false; - while (Date.now() - start < 10000) { - const pid = getPid(); - if (pid && !pids.includes(pid)) { - currentPid = pid; - pids.push(pid); - restarted = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - expect( - restarted, - `Daemon should have restarted after crash ${i + 1}`, - ).toBe(true); - } - - process.kill(currentPid, "SIGKILL"); - - await new Promise((resolve) => setTimeout(resolve, 5000)); - expect( - existsSync(pidFile), - "PID file should not exist after exceeding max restarts", - ).toBe(false); - - if (existsSync(stateFile)) { - const state = JSON.parse(readFileSync(stateFile, "utf-8")); - expect(state.status).toBe("error"); - expect(state.lastError).toContain("crashed 3 times in 5 minutes"); - } - - supervisorProcess.kill("SIGKILL"); - }, 60000); -}); From fcaa07d770c7f8400c579239bd848ae8d4a02f1c Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 14:38:57 +0530 Subject: [PATCH 5/7] refactor(cli): drive the single app via a lazy-spawn launcher Add app-launcher.ts (locates dist/app + overlay's Electron binary, strips ELECTRON_OZONE_PLATFORM_HINT so the window comes up as an XWayland client, spawns detached/foreground). start/stop/restart now target the app process; toggle lazily respawns a dead app before delivering the trigger (the crash-recovery path). install prints Hyprland exec-once guidance instead of writing a systemd unit. Remove the standalone overlay command; reword health and setup for the single-app topology. --- src/cli/app-launcher.ts | 77 ++++++++ src/cli/health.ts | 37 +--- src/cli/index.ts | 380 ++++++++++++++++------------------------ src/cli/overlay.ts | 196 --------------------- src/cli/setup.ts | 4 +- 5 files changed, 241 insertions(+), 453 deletions(-) create mode 100644 src/cli/app-launcher.ts delete mode 100644 src/cli/overlay.ts diff --git a/src/cli/app-launcher.ts b/src/cli/app-launcher.ts new file mode 100644 index 0000000..5d41aac --- /dev/null +++ b/src/cli/app-launcher.ts @@ -0,0 +1,77 @@ +import { spawn } from "node:child_process"; +import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { projectRoot } from "../utils/project-paths"; + +// The single Electron app (daemon + overlay window). Built by `bun run +// build:app`; the Electron runtime is the overlay package's dependency. +export const appDir = join(projectRoot, "dist", "app"); +export const electronBinary = join( + projectRoot, + "overlay", + "node_modules", + ".bin", + "electron", +); + +const configDir = join(homedir(), ".config", "hypr", "vox"); +const logsDir = join(configDir, "logs"); + +/** + * Spawn environment for the app. ELECTRON_OZONE_PLATFORM_HINT must not reach + * the Electron process: the overlay depends on XWayland (self-positioning), + * and hint=auto — common in Hyprland sessions — either selects native Wayland + * or, combined with the app's --ozone-platform=x11 pin, leaves Electron with + * no window at all. Electron reads the hint before the app's JS runs, so it + * can only be stripped here. + */ +function buildAppEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.ELECTRON_OZONE_PLATFORM_HINT; + return env; +} + +export function assertAppLaunchable(): void { + if (!existsSync(join(appDir, "main.js"))) { + throw new Error( + `App bundle not found at ${appDir}. Build it with: bun run build:app`, + ); + } + if (!existsSync(electronBinary)) { + throw new Error( + `Electron binary not found at ${electronBinary}. Install overlay deps with: cd overlay && npm install`, + ); + } +} + +/** + * Launch the app detached, logging to ~/.config/hypr/vox/logs/app.log. + * The service writes its own pidfile once it is actually up; callers that + * need the pid should poll the pidfile rather than trust the spawn result. + */ +export function spawnAppDetached(): number | undefined { + assertAppLaunchable(); + mkdirSync(logsDir, { recursive: true, mode: 0o700 }); + const logFd = openSync(join(logsDir, "app.log"), "a"); + + const child = spawn(electronBinary, [appDir], { + detached: true, + stdio: ["ignore", logFd, logFd], + env: buildAppEnv(), + }); + child.unref(); + closeSync(logFd); + return child.pid; +} + +export function spawnAppForeground(): void { + assertAppLaunchable(); + const child = spawn(electronBinary, [appDir], { + stdio: "inherit", + env: buildAppEnv(), + }); + child.on("exit", (code) => { + process.exit(code ?? 0); + }); +} diff --git a/src/cli/health.ts b/src/cli/health.ts index 0e4bb5c..c89cce6 100644 --- a/src/cli/health.ts +++ b/src/cli/health.ts @@ -266,58 +266,35 @@ export const healthCommand = new Command("health") .trim(); if (isServiceActive === "active") { console.log( - `${colors.green("✅")} systemd service: ${colors.green("active")}`, + `${colors.yellow("⚠️")} Legacy systemd service is active — the app now supervises itself; remove it with ${colors.cyan("hyprvox uninstall")}`, ); } else { console.log( - `${colors.yellow("⚠️")} systemd service: ${colors.yellow(isServiceActive)}`, + `${colors.blue("ℹ️")} Legacy systemd service present but ${isServiceActive}`, ); } } catch (_e) { console.log( - `${colors.blue("ℹ️")} systemd service not active or not installed`, + `${colors.blue("ℹ️")} No legacy systemd service (expected; app is launched via exec-once)`, ); } // 6. Overlay Check console.log(`\n${colors.bold("--- Overlay Status ---")}`); - const overlayPidFile = join(configDir, "overlay.pid"); if (config?.overlay?.enabled === false) { console.log(`${colors.blue("ℹ️")} Overlay is disabled in config`); } else { - if (existsSync(overlayPidFile)) { - const overlayPid = parseInt( - readFileSync(overlayPidFile, "utf-8").trim(), - 10, - ); - try { - process.kill(overlayPid, 0); - console.log( - `${colors.green("✅")} Overlay is running (${colors.dim(`PID: ${overlayPid}`)})`, - ); - } catch { - console.log( - `${colors.yellow("⚠️")} Overlay PID file exists but process is dead`, - ); - } - } else { - console.log(`${colors.blue("ℹ️")} Overlay is not running`); - if (config?.overlay?.autoStart) { - console.log( - `${colors.yellow("⚠️")} Auto-start is enabled but overlay not running`, - ); - } - } - + // The overlay window is hosted by the app process itself; a live + // command socket means the app (and therefore the window) is up. const socketPath = join(configDir, "daemon.sock"); if (existsSync(socketPath)) { console.log( - `${colors.green("✅")} IPC socket available for overlay connection`, + `${colors.green("✅")} App command socket present (overlay window hosted in-process)`, ); } else { console.log( - `${colors.yellow("⚠️")} IPC socket not found (daemon not running?)`, + `${colors.yellow("⚠️")} Command socket not found (app not running?)`, ); } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 957a9a7..4b60771 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,26 +1,21 @@ import { execSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { Command } from "commander"; import * as colors from "yoctocolors"; import { AudioDeviceService } from "../audio/device-service"; -import { DaemonService, type DaemonState } from "../daemon/service"; -import { DaemonSupervisor } from "../daemon/supervisor"; +import type { DaemonState } from "../daemon/service"; +import { projectRoot } from "../utils/project-paths"; +import { SOCKET_PATH } from "../utils/socket-path"; import { loadStats } from "../utils/stats"; +import { spawnAppDetached, spawnAppForeground } from "./app-launcher"; import { boostCommand } from "./boost"; import { configCommand } from "./config"; import { errorsCommand } from "./errors"; import { healthCommand } from "./health"; import { historyCommand } from "./history"; import { logsCommand } from "./logs"; -import { overlayCommand } from "./overlay"; import { setupCommand } from "./setup"; import { statsCommand } from "./stats"; @@ -28,9 +23,6 @@ const program = new Command(); const configDir = join(homedir(), ".config", "hypr", "vox"); const pidFile = join(configDir, "daemon.pid"); const stateFile = join(configDir, "daemon.state"); -// Resolve project root relative to this file so the CLI works when invoked -// as a global binary from any working directory (not just the project root). -const projectRoot = join(import.meta.dir, "..", ".."); interface PackageMetadata { version: string; @@ -56,84 +48,83 @@ program .description("Speech-to-text daemon for Hyprland") .version(packageMetadata.version); -program - .command("start") - .description("Start the daemon") - .option("--no-supervisor", "Run directly without supervisor") - .option("--daemon-worker", "Internal: Run as daemon worker process") - .action((options) => { - if (existsSync(pidFile) && !process.env.HYPRVOX_DAEMON_WORKER) { - try { - const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); - try { - process.kill(pid, 0); - console.error( - colors.red(`Error: Daemon is already running (PID: ${pid})`), - ); - console.log( - `To stop the daemon, run: ${colors.cyan("hyprvox stop")}`, - ); - console.log( - `Or if using systemd: ${colors.cyan("systemctl --user stop hyprvox")}`, - ); - process.exit(1); - } catch (killError: unknown) { - if ( - killError instanceof Error && - "code" in killError && - (killError as NodeJS.ErrnoException).code === "ESRCH" - ) { - // Process doesn't exist, clean up stale PID file - console.log( - colors.yellow( - "Cleaning up stale PID file from previous session...", - ), - ); - try { - unlinkSync(pidFile); - } catch { - // PID file may have already been removed - } - } else { - throw killError; - } - } - } catch { - // Failed to read PID file, assume not running - } +/** Pid from the pidfile if that process is alive; null otherwise. */ +function readAlivePid(): number | null { + if (!existsSync(pidFile)) { + return null; + } + try { + const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); + if (Number.isNaN(pid)) { + return null; } + process.kill(pid, 0); + return pid; + } catch { + return null; + } +} - if (options.supervisor && !process.env.HYPRVOX_DAEMON_WORKER) { - console.log(`${colors.cyan("Starting daemon with supervisor...")}`); - const supervisor = new DaemonSupervisor(join(projectRoot, "index.ts")); - supervisor.start(); - } else { - console.log(`${colors.cyan("Starting daemon worker...")}`); - let service: DaemonService; - try { - service = new DaemonService(); - service.start().catch((err) => { - console.error(colors.red("\nFailed to start daemon:"), err.message); - process.exit(1); - }); - } catch (err: any) { - console.error( - colors.red("\nFailed to initialize daemon:"), - err.message, - ); - process.exit(1); - } +function cleanStalePidFile(): void { + if (existsSync(pidFile) && readAlivePid() === null) { + console.log( + colors.yellow("Cleaning up stale PID file from previous session..."), + ); + try { + unlinkSync(pidFile); + } catch { + // PID file may have already been removed + } + } +} - process.on("SIGINT", () => { - service.stop(); - process.exit(0); - }); +/** Wait for the app's DaemonService to write its pidfile after a spawn. */ +async function waitForAlivePid(timeoutMs = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const pid = readAlivePid(); + if (pid !== null) { + return pid; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return null; +} - process.on("SIGTERM", () => { - service.stop(); - process.exit(0); - }); +function startApp(foreground: boolean): void { + const runningPid = readAlivePid(); + if (runningPid !== null) { + console.error( + colors.red(`Error: hyprvox is already running (PID: ${runningPid})`), + ); + console.log(`To stop it, run: ${colors.cyan("hyprvox stop")}`); + process.exit(1); + } + cleanStalePidFile(); + + try { + if (foreground) { + console.log(`${colors.cyan("Starting hyprvox app (foreground)...")}`); + spawnAppForeground(); + } else { + console.log(`${colors.cyan("Starting hyprvox app...")}`); + const pid = spawnAppDetached(); + console.log( + `${colors.green("✅")} App launched${pid ? ` (${colors.dim(`PID: ${pid}`)})` : ""} — logs: ${colors.dim(join(configDir, "logs", "app.log"))}`, + ); } + } catch (err) { + console.error(colors.red("Failed to start app:"), (err as Error).message); + process.exit(1); + } +} + +program + .command("start") + .description("Start the hyprvox app (daemon + overlay in one process)") + .option("--foreground", "Stay attached to the terminal (for debugging)") + .action((options) => { + startApp(Boolean(options.foreground)); }); program @@ -178,19 +169,29 @@ program program .command("restart") - .description("Restart the daemon") + .description("Restart the hyprvox app") .action(async () => { if (existsSync(pidFile)) { try { const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); process.kill(pid, "SIGTERM"); - console.log(colors.yellow("Stopping daemon...")); - await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log(colors.yellow("Stopping app...")); + // Wait for the old process to actually exit; spawning while it + // still holds the pidfile/socket would fail the instance guard. + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch { + break; + } + } if (existsSync(stateFile)) unlinkSync(stateFile); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== "ESRCH") { - console.error(colors.red("Failed to stop daemon:"), err); + console.error(colors.red("Failed to stop app:"), err); process.exit(1); } console.log(colors.yellow("Cleaning up stale PID file...")); @@ -198,9 +199,7 @@ program if (existsSync(stateFile)) unlinkSync(stateFile); } } - console.log(colors.cyan("Starting daemon...")); - const supervisor = new DaemonSupervisor(join(projectRoot, "index.ts")); - supervisor.start(); + startApp(false); }); program @@ -276,30 +275,42 @@ program program .command("toggle") - .description("Toggle recording (start/stop)") - .action(() => { - if (!existsSync(pidFile)) { - console.error(colors.red("Error: Daemon is not running.")); - console.log(`Start it with: ${colors.cyan("hyprvox start")}`); - process.exit(1); + .description("Toggle recording (start/stop); starts the app if it is down") + .action(async () => { + let pid = readAlivePid(); + + // Lazy-spawn crash recovery: if the app died (or was never started), + // a toggle brings it back up and then delivers the trigger. + if (pid === null) { + cleanStalePidFile(); + if (existsSync(stateFile)) unlinkSync(stateFile); + console.log(colors.yellow("App not running; starting it...")); + try { + spawnAppDetached(); + } catch (err) { + console.error( + colors.red("Failed to start app:"), + (err as Error).message, + ); + process.exit(1); + } + pid = await waitForAlivePid(); + if (pid === null) { + console.error( + colors.red("App did not come up in time. Check logs:"), + colors.dim(join(configDir, "logs", "app.log")), + ); + process.exit(1); + } } try { - const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); process.kill(pid, "SIGUSR1"); console.log( `${colors.green("✅")} Toggle signal sent to daemon (${colors.dim(`PID: ${pid}`)})`, ); } catch (error) { - const err = error as NodeJS.ErrnoException; - console.error(colors.red("Failed to send toggle signal:"), err); - if (err.code !== "ESRCH") { - process.exit(1); - } - console.log(colors.yellow("Cleaning up stale PID file...")); - if (existsSync(pidFile)) unlinkSync(pidFile); - if (existsSync(stateFile)) unlinkSync(stateFile); - console.log(`Start the daemon with: ${colors.cyan("hyprvox start")}`); + console.error(colors.red("Failed to send toggle signal:"), error); process.exit(1); } }); @@ -308,21 +319,14 @@ program .command("soniox-toggle") .description("Toggle Soniox live dictation (start/stop)") .action(async () => { - const socketPath = join( - homedir(), - ".config", - "hypr", - "vox", - "daemon.sock", - ); - if (!existsSync(socketPath)) { + if (!existsSync(SOCKET_PATH)) { console.error(colors.red("Error: Daemon is not running.")); console.log(`Start it with: ${colors.cyan("hyprvox start")}`); process.exit(1); } const { createConnection } = await import("node:net"); - const client = createConnection({ path: socketPath }); + const client = createConnection({ path: SOCKET_PATH }); client.on("connect", () => { client.write( @@ -341,128 +345,55 @@ program program .command("install") - .description("Install systemd service") + .description("Show how to autostart hyprvox with Hyprland") .action(() => { - try { - const serviceName = "hyprvox"; - const serviceDir = join(homedir(), ".config", "systemd", "user"); - const logsDir = join(configDir, "logs"); - const servicePath = join(serviceDir, `${serviceName}.service`); - const workingDir = projectRoot; - const bunPath = process.argv[0]; - const entryPoint = join(projectRoot, "index.ts"); - const userId = process.getuid?.() ?? 1000; - - if (!existsSync(serviceDir)) { - mkdirSync(serviceDir, { recursive: true }); - } - - if (!existsSync(logsDir)) { - console.log(`Creating log directory: ${logsDir}`); - mkdirSync(logsDir, { recursive: true, mode: 0o700 }); - } - - console.log(`Installing systemd service for ${serviceName}...`); - - const serviceContent = `[Unit] -Description=Hyprvox Daemon -After=network.target sound.target -StartLimitIntervalSec=300 -StartLimitBurst=3 - -[Service] -Type=simple -WorkingDirectory=${workingDir} -ExecStart=${bunPath} run ${entryPoint} start --no-supervisor -Restart=always -RestartSec=5 -Environment=PATH=${process.env.PATH} -Environment=DISPLAY=${process.env.DISPLAY || ""} -Environment=XAUTHORITY=${process.env.XAUTHORITY || ""} -Environment=WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY || ""} -Environment=XDG_RUNTIME_DIR=/run/user/${userId} - -[Install] -WantedBy=default.target -`; - - writeFileSync(servicePath, serviceContent); - console.log("Service file created."); - - console.log("Reloading systemd daemon..."); - execSync("systemctl --user daemon-reload"); - - console.log(`Enabling ${serviceName} service...`); - execSync(`systemctl --user enable ${serviceName}`); - - console.log(`Starting ${serviceName} service...`); - execSync(`systemctl --user start ${serviceName}`); - - const configPath = join(configDir, "config.json"); - const configExists = existsSync(configPath); - - console.log( - `\n${colors.green("------------------------------------------------")}`, - ); - console.log(colors.bold(" Installation complete! 🚀")); - - let statusStr = colors.red("Inactive"); - try { - const isActive = execSync(`systemctl --user is-active ${serviceName}`) - .toString() - .trim(); - if (isActive === "active") statusStr = colors.green("Active"); - else if (isActive === "activating") - statusStr = colors.yellow("Activating"); - } catch { - // Service not active, show default "Inactive" - } - console.log(` Status: ${statusStr}`); - console.log( - colors.green("------------------------------------------------"), - ); - - console.log(colors.bold("\nNext Steps:")); - - if (!configExists) { - console.log( - ` 1. ${colors.yellow("CRITICAL:")} Initialize your API keys:`, - ); - console.log(` ${colors.cyan("bun run index.ts config init")}`); - } else { - console.log(` 1. Verify your configuration:`); - console.log(` ${colors.cyan("bun run index.ts config list")}`); - } + // The app supervises itself (Electron is the single supervisor, + // ADR-0003); autostart is a compositor exec-once, not a systemd unit. + const logsDir = join(configDir, "logs"); + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true, mode: 0o700 }); + } - console.log(` 2. Select your microphone device:`); - console.log(` ${colors.cyan("bun run index.ts list-mics")}`); + const configPath = join(configDir, "config.json"); + const legacyUnit = join( + homedir(), + ".config", + "systemd", + "user", + "hyprvox.service", + ); - console.log(` 3. Configure your hotkey (default: Right Control):`); - console.log(` ${colors.cyan("bun run index.ts config bind")}`); + console.log(colors.bold("\nAutostart hyprvox with Hyprland:")); + console.log("\nAdd to your hyprland.conf:"); + console.log(` ${colors.cyan("exec-once = hyprvox start")}`); + console.log("\nRecommended toggle binding (example):"); + console.log( + ` ${colors.cyan("bind = , code:105, exec, hyprvox toggle")} ${colors.dim("# Right Control")}`, + ); + console.log( + `\nIf hyprvox dies, the next ${colors.cyan("hyprvox toggle")} restarts it automatically.`, + ); - console.log(colors.bold("\nVerification:")); + if (existsSync(legacyUnit)) { console.log( - ` - Check service status: ${colors.cyan(`systemctl --user status ${serviceName}`)}`, + `\n${colors.yellow("⚠️")} Legacy systemd unit found at ${colors.dim(legacyUnit)}.`, ); console.log( - ` - Follow live logs: ${colors.cyan(`journalctl --user -u ${serviceName} -f`)}`, + ` Remove it with: ${colors.cyan("hyprvox uninstall")} (the app now supervises itself).`, ); + } - console.log(colors.bold("\nFiles:")); - console.log(` - Config: ${colors.dim(configPath)}`); - console.log(` - Logs: ${colors.dim(logsDir)}`); + if (!existsSync(configPath)) { console.log( - colors.green("------------------------------------------------\n"), + `\n${colors.yellow("CRITICAL:")} Initialize your API keys first: ${colors.cyan("hyprvox config init")}`, ); - } catch (error) { - console.error("Installation failed:", (error as Error).message); - process.exit(1); } + console.log(""); }); program .command("uninstall") - .description("Remove systemd service") + .description("Remove the legacy systemd service") .action(() => { const serviceName = "hyprvox"; const serviceDir = join(homedir(), ".config", "systemd", "user"); @@ -562,7 +493,6 @@ program.addCommand(boostCommand); program.addCommand(healthCommand); program.addCommand(errorsCommand); program.addCommand(historyCommand); -program.addCommand(overlayCommand); program.addCommand(setupCommand); program.addCommand(statsCommand); diff --git a/src/cli/overlay.ts b/src/cli/overlay.ts deleted file mode 100644 index 09b56f0..0000000 --- a/src/cli/overlay.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { Command } from "commander"; -import * as colors from "yoctocolors"; -import { loadConfig } from "../config/loader"; -import { SOCKET_PATH } from "../daemon/ipc"; -import { getBundledOverlayPath } from "../utils/project-paths"; - -const configDir = join(homedir(), ".config", "hypr", "vox"); -const overlayPidFile = join(configDir, "overlay.pid"); - -function getDefaultOverlayPath(): string { - return getBundledOverlayPath(); -} - -function getOverlayBinaryPath(): string { - const config = loadConfig(); - if (config.overlay?.binaryPath) { - return config.overlay.binaryPath; - } - return getDefaultOverlayPath(); -} - -function isOverlayRunning(): { running: boolean; pid?: number } { - if (!existsSync(overlayPidFile)) { - return { running: false }; - } - - try { - const pid = parseInt(readFileSync(overlayPidFile, "utf-8").trim(), 10); - process.kill(pid, 0); - return { running: true, pid }; - } catch { - try { - unlinkSync(overlayPidFile); - } catch { - // File may have been deleted concurrently - } - return { running: false }; - } -} - -function startOverlay(): void { - const { running, pid } = isOverlayRunning(); - if (running) { - console.log( - `${colors.yellow("Overlay is already running")} (${colors.dim(`PID: ${pid}`)})`, - ); - return; - } - - const overlayPath = getOverlayBinaryPath(); - - if (!existsSync(overlayPath)) { - console.error( - `${colors.red("Error:")} Overlay not found at ${colors.dim(overlayPath)}`, - ); - console.log( - `Build it first: ${colors.cyan(`cd ${overlayPath} && bun install && bun run build`)}`, - ); - process.exit(1); - } - - console.log(`${colors.cyan("Starting overlay...")}`); - - const child = spawn("bun", ["run", "start"], { - cwd: overlayPath, - detached: true, - stdio: "ignore", - }); - - child.unref(); - - if (!child.pid) { - console.error( - `${colors.red("Error:")} Overlay spawned but no PID was assigned`, - ); - return; - } - - writeFileSync(overlayPidFile, child.pid.toString()); - - console.log( - `${colors.green("✅")} Overlay started (${colors.dim(`PID: ${child.pid}`)})`, - ); -} - -function stopOverlay(): void { - const { running, pid } = isOverlayRunning(); - if (!running) { - console.log(`${colors.yellow("Overlay is not running")}`); - return; - } - - try { - process.kill(pid!, "SIGTERM"); - } catch (error) { - console.error(`${colors.red("Error:")} Failed to stop overlay:`, error); - return; - } - - console.log( - `${colors.green("✅")} Stopped overlay (${colors.dim(`PID: ${pid}`)})`, - ); - try { - unlinkSync(overlayPidFile); - } catch { - // PID file may have already been removed - } -} - -function waitForProcessExit(pid: number, timeoutMs = 3000): Promise { - return new Promise((resolve) => { - const start = Date.now(); - const interval = setInterval(() => { - try { - process.kill(pid, 0); - } catch { - clearInterval(interval); - resolve(); - return; - } - if (Date.now() - start >= timeoutMs) { - clearInterval(interval); - resolve(); - } - }, 50); - }); -} - -async function restartOverlay(): Promise { - const { pid } = isOverlayRunning(); - stopOverlay(); - if (pid) { - await waitForProcessExit(pid); - } - startOverlay(); -} - -function statusOverlay(): void { - const config = loadConfig(); - const { running, pid } = isOverlayRunning(); - - console.log( - `${colors.dim("Status:")} ${running ? colors.green("Running") : colors.red("Stopped")}`, - ); - - if (running && pid) { - console.log(`${colors.dim("PID:")} ${pid}`); - } - - console.log( - `${colors.dim("Enabled:")} ${config.overlay?.enabled ? colors.green("true") : colors.red("false")}`, - ); - console.log( - `${colors.dim("Auto-start:")} ${config.overlay?.autoStart ? colors.green("true") : colors.red("false")}`, - ); - console.log(`${colors.dim("Path:")} ${colors.dim(getOverlayBinaryPath())}`); - - if (existsSync(SOCKET_PATH)) { - console.log(`${colors.dim("IPC:")} ${colors.green("Socket available")}`); - } else { - console.log( - `${colors.dim("IPC:")} ${colors.yellow("Socket not found (daemon not running?)")}`, - ); - } -} - -export const overlayCommand = new Command("overlay") - .description("Manage the waveform overlay") - .action(() => { - statusOverlay(); - }); - -overlayCommand - .command("start") - .description("Start the overlay") - .action(() => { - startOverlay(); - }); - -overlayCommand - .command("stop") - .description("Stop the overlay") - .action(() => { - stopOverlay(); - }); - -overlayCommand - .command("restart") - .description("Restart the overlay") - .action(() => { - restartOverlay(); - }); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index fd8c1bb..088e761 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -847,7 +847,7 @@ function installServiceIfRequested( return; } - if (!askYesNoQuit("Install and start the systemd user service now?")) { + if (!askYesNoQuit("Show Hyprland autostart instructions now?")) { return; } @@ -1001,7 +1001,7 @@ export const setupCommand = new Command("setup") .option("--check", "Run setup checks without changing anything") .option("--json", "Print setup check output as JSON") .option("--dry-run", "Show what setup would change without writing") - .option("--skip-service", "Do not install or start the systemd user service") + .option("--skip-service", "Do not show autostart (exec-once) instructions") .option( "--non-interactive", "Run setup without prompts (automation-friendly)", From d3ade2cd73bed0576a85b635a861e6c4ab5d935d Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 14:39:03 +0530 Subject: [PATCH 6/7] build(single-app): add app bundler and Electron-typed app tsconfig scripts/build-app.ts esbuild-bundles src/app/main.ts to dist/app/main.js (packages external, electron external, tsconfigRaw override to defeat path mapping, ESM-compat banner) and writes dist/app/package.json with name hyprvox-overlay so the window's WM_CLASS is hyprvox-overlay. src/app/tsconfig.json extends root and points electron types at overlay's node_modules. package.json gains typecheck/typecheck:app/build:app scripts and the esbuild devDependency; start:overlay removed. --- bun.lock | 1 + package.json | 5 +++- scripts/build-app.ts | 68 +++++++++++++++++++++++++++++++++++++++++++ src/app/tsconfig.json | 16 ++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 scripts/build-app.ts create mode 100644 src/app/tsconfig.json diff --git a/bun.lock b/bun.lock index 2ff9bef..c04cb54 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@types/pino": "^7.0.5", "@types/react": "^19.2.0", "@vitest/coverage-v8": "^4.0.17", + "esbuild": "^0.27.3", "vitest": "^4.0.17", }, "peerDependencies": { diff --git a/package.json b/package.json index 9d0d848..a26cb49 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,10 @@ "lint": "biome lint .", "format": "biome format --write .", "check": "biome check --write .", + "typecheck": "tsc --noEmit", + "typecheck:app": "tsc -p src/app/tsconfig.json", "build:overlay": "cd overlay && bun install && bun run build", - "start:overlay": "cd overlay && bun run start" + "build:app": "bun run scripts/build-app.ts" }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -45,6 +47,7 @@ "@types/pino": "^7.0.5", "@types/react": "^19.2.0", "@vitest/coverage-v8": "^4.0.17", + "esbuild": "^0.27.3", "vitest": "^4.0.17" }, "peerDependencies": { diff --git a/scripts/build-app.ts b/scripts/build-app.ts new file mode 100644 index 0000000..0fdb3fc --- /dev/null +++ b/scripts/build-app.ts @@ -0,0 +1,68 @@ +// Build the single Electron app (daemon + overlay window) into dist/app. +// +// Bundle ONLY our own code (packages: "external"): several dependencies do +// not survive inlining — node-record-lpcm16 (dynamic require), +// node-global-key-listener (vendored binaries), clipboardy (import.meta.url +// asset resolution breaks silently). They resolve from the repo's +// node_modules at runtime instead. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = join(repoRoot, "dist", "app"); + +// The ESM bundle still needs CJS-era globals for code paths that use +// require/__dirname (our own code after esbuild's CJS->ESM conversion). +const esmCompatBanner = [ + "import{createRequire as __cr}from'module';", + "import{fileURLToPath as __ftp}from'url';", + "import{dirname as __dn}from'path';", + "const require=__cr(import.meta.url);", + "const __filename=__ftp(import.meta.url);", + "const __dirname=__dn(__filename);", +].join(""); + +await build({ + entryPoints: [join(repoRoot, "src", "app", "main.ts")], + outfile: join(outDir, "main.js"), + bundle: true, + platform: "node", + format: "esm", + target: "node18", + packages: "external", + // src/app/tsconfig.json paths-maps "electron" into overlay/node_modules + // so tsc can find its types; esbuild honors that mapping, which turns the + // bare import into a resolvable directory and defeats packages:"external" + // (the npm installer shim gets inlined). Ignore tsconfig paths here and + // pin electron external — it is provided by the Electron runtime itself. + tsconfigRaw: { compilerOptions: {} }, + external: ["electron"], + banner: { js: esmCompatBanner }, + logLevel: "info", +}); + +const rootPkg = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf-8"), +); + +// Electron derives the window's WM_CLASS from this package.json name; the +// user's Hyprland window rules target class `hyprvox-overlay`, so this name +// is load-bearing — launching the bare bundle instead yields class +// `Electron` and none of the rules match. +const appPkg = { + name: "hyprvox-overlay", + version: rootPkg.version, + private: true, + type: "module", + main: "main.js", +}; + +mkdirSync(outDir, { recursive: true }); +writeFileSync( + join(outDir, "package.json"), + `${JSON.stringify(appPkg, null, "\t")}\n`, +); + +console.log(`Built app bundle: ${join(outDir, "main.js")}`); diff --git a/src/app/tsconfig.json b/src/app/tsconfig.json new file mode 100644 index 0000000..c6e86af --- /dev/null +++ b/src/app/tsconfig.json @@ -0,0 +1,16 @@ +{ + // The app entry runs under Electron (Node), not Bun, and the root + // tsconfig excludes src/app. Electron's types live only in the overlay + // package's node_modules, hence the paths mapping. + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "electron": ["../../overlay/node_modules/electron"] + } + }, + // Ambient module declarations live outside src/app and must be included + // explicitly, since this config's include starts at src/app. + "include": ["./**/*.ts", "../types/**/*.d.ts"], + // Root config excludes src/app; neutralize the inherited exclude. + "exclude": ["../../overlay/node_modules"] +} From 88c632bfe18b1dd3a54b426aefb51ed1d0955aae Mon Sep 17 00:00:00 2001 From: Atulya Date: Wed, 15 Jul 2026 14:39:12 +0530 Subject: [PATCH 7/7] docs(single-app): document ADR-0003 single-app topology Add ADR-0003 (the daemon+overlay collapse) with Phase 1 implementation notes on the XWayland dependency, WM_CLASS source, command socket, and the drop of the systemd install path. Update ARCHITECTURE, CLI_COMMANDS, and AGENTS to describe the single Electron app, exec-once launch, lazy-spawn crash recovery, and the command socket. --- AGENTS.md | 10 +- docs/ARCHITECTURE.md | 21 +++-- docs/CLI_COMMANDS.md | 25 ++--- ...aemon-and-overlay-into-one-electron-app.md | 93 +++++++++++++++++++ 4 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.md diff --git a/AGENTS.md b/AGENTS.md index a80e1ab..9688f06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,10 @@ Lean repo guide for automated agents. - Product requirements live in GitHub issues (epics + child tickets), not a local `PRD.md`. ## Stack -- Bun runtime and package manager +- Bun runtime and package manager (CLI/tests); the app itself runs under Electron/Node - Strict TypeScript - pino logs with daily rotation -- Electron overlay sidecar over local IPC +- Single Electron app hosts the daemon and the overlay window (ADR-0003); state flows main → renderer via webContents.send ## Current Product State - Hyprvox uses parallel Groq + Deepgram transcription with merge/validation/recovery. @@ -30,10 +30,10 @@ Lean repo guide for automated agents. ## Operational Data - Config: `~/.config/hypr/vox/config.json` -- Logs: `paths.logs` from config +- Logs: `paths.logs` from config; app stdout/stderr in `~/.config/hypr/vox/logs/app.log` - History: `~/.config/voice-cli/history.json` -- IPC socket: `~/.config/hypr/vox/daemon.sock` -- Overlay PID file: `~/.config/hypr/vox/overlay.pid` +- Command socket (single-instance guard + `soniox-toggle`): `~/.config/hypr/vox/daemon.sock` +- App bundle: `dist/app` (built by `bun run build:app`; `package.json` name there sets WM_CLASS) ## Workflow Notes - Default hotkey: Right Control; Hyprland users often bind `hyprvox toggle` in the compositor. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index beb8e61..33d05d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,24 +6,29 @@ ``` src/ +├── app/ # Electron main: hosts the daemon + overlay window (single process) ├── audio/ # Recording and audio device management ├── cli/ # CLI command implementations ├── config/ # Configuration loading, validation, and storage -├── daemon/ # Background service, hotkey handling, and supervisor +├── daemon/ # Background service and hotkey handling ├── output/ # Clipboard and notification integration +├── shared/ # IPC message types shared with the overlay renderer ├── transcribe/ # External API integrations (Groq & Deepgram) ├── utils/ # Shared utilities and helpers ├── types/ # External type definitions +overlay/ # Renderer + preload assets for the overlay window (built to overlay/dist) ``` ## Core Architecture -### 1. Daemon Lifecycle & Supervision -`hyprvox` operates as a persistent background daemon on Linux. It follows a multi-process architecture where a **Supervisor** ensures high availability of a **Worker** process. +### 1. Single-App Topology (ADR-0003) +`hyprvox` is one resident Electron app: the main process hosts the daemon (`DaemonService`) and the overlay `BrowserWindow`. There is no supervisor process, no systemd unit, and no daemon↔overlay socket — Electron is the single supervisor. -- **Supervisor (`src/daemon/supervisor.ts`)**: The parent process that spawns and monitors the worker. It implements auto-restart logic with crash protection (max 3 crashes in 5 minutes). -- **Service (`src/daemon/service.ts`)**: The main "Event Loop" and orchestrator for the worker process. It maintains system state and coordinates between hardware (audio/keyboard) and remote APIs. -- **Systemd Integration**: The daemon can be managed as a systemd user service, which handles environment forwarding (`DISPLAY`, `WAYLAND_DISPLAY`) for clipboard and notification access. +- **App main (`src/app/main.ts`)**: Boots the command socket (single-instance guard), starts `DaemonService` in-process, creates the overlay window, and forwards daemon state to the renderer via `webContents.send`. +- **Service (`src/daemon/service.ts`)**: The orchestrator. Maintains the state machine and coordinates hardware (audio/keyboard) with remote APIs. Emits `state` and `audioLevel` events (it is an `EventEmitter`); the app main relays them to the renderer. +- **Command socket (`src/app/command-server.ts`)**: A minimal unix socket (`daemon.sock`) for CLI verbs that need a payload (`soniox-toggle`); binding it doubles as the single-instance guard. +- **Launch & crash recovery**: Started via Hyprland `exec-once = hyprvox start`. If the app dies, the next `hyprvox toggle` lazily respawns it. +- **Window identity**: The window must map as an XWayland client with `WM_CLASS` `hyprvox-overlay` (Hyprland rules target that class, and self-positioning — how the overlay parks off-screen — only works under XWayland). The launcher strips `ELECTRON_OZONE_PLATFORM_HINT` and the app pins `--ozone-platform=x11`; the class comes from `dist/app/package.json`'s `name` field written by `bun run build:app`. ### 2. State Machine The daemon tracks its status via a formal state machine to ensure predictable behavior: @@ -112,7 +117,9 @@ For details on using these modules programmatically, see the [Programmatic API R ### Getting Started 1. Clone the repository: `git clone https://github.com/Snehit70/hyprvox.git` 2. Install dependencies: `bun install` -3. Run in development mode: `bun run index.ts start` +3. Build the overlay assets (also installs the Electron runtime): `bun run build:overlay` +4. Build the app bundle: `bun run build:app` +5. Start the app: `bun run index.ts start` (add `--foreground` to stay attached) ### Testing We use [Vitest](https://vitest.dev/) for testing. diff --git a/docs/CLI_COMMANDS.md b/docs/CLI_COMMANDS.md index 24d3f7b..04c2c59 100644 --- a/docs/CLI_COMMANDS.md +++ b/docs/CLI_COMMANDS.md @@ -35,31 +35,34 @@ If `hyprvox status` shows a manually started daemon before service installation, ## Main Commands ### `start` -Start the transcription daemon. +Start the hyprvox app — the daemon and the overlay window run in one Electron process (ADR-0003). Launches detached; logs go to `~/.config/hypr/vox/logs/app.log`. Requires the app bundle (`bun run build:app`) and overlay assets (`bun run build:overlay`) to have been built. - **Options:** - - `--no-supervisor`: Run the daemon directly without the auto-restarting supervisor. - - `--daemon-worker`: (Internal) Used by the supervisor to spawn worker processes. + - `--foreground`: Stay attached to the terminal (for debugging). - `--help`: Display help for the start command. ### `stop` -Stop the running transcription daemon. +Stop the running app. - **Options:** - `--help`: Display help for the stop command. ### `restart` -Restart the transcription daemon. +Restart the app (SIGTERM, wait for exit, relaunch). - **Options:** - `--help`: Display help for the restart command. +### `toggle` +Toggle recording. If the app is not running, it is started first and the trigger is delivered once it is up (this is the crash-recovery path). + +### `soniox-toggle` +Toggle Soniox live dictation via the app's command socket (`~/.config/hypr/vox/daemon.sock`). + ### `status` Display the current status of the daemon (PID, uptime, state, statistics). - **Options:** - `--help`: Display help for the status command. ### `install` -Install `hyprvox` as a systemd user service for the current user. - -Recommended after `config init`, `list-mics`, and `health` have passed. The command writes `~/.config/systemd/user/hyprvox.service`, runs `systemctl --user daemon-reload`, enables the service, and starts it. +Print instructions for autostarting hyprvox with Hyprland (`exec-once = hyprvox start`) and a suggested toggle binding. No systemd unit is written — the app supervises itself, and a dead app is revived by the next `hyprvox toggle`. Warns if a legacy systemd unit is still present. - **Options:** - `--help`: Display help for the install command. @@ -67,17 +70,17 @@ Recommended after `config init`, `list-mics`, and `health` have passed. The comm ### `setup` Interactively set up `hyprvox` and diagnose the host environment. -The setup command detects Linux distro, Wayland/X11/headless session, container state, required commands, config validity, daemon status, and service installation. In interactive mode it can create/update config, select a microphone, recommend Wayland compositor binding behavior, and install the service. +The setup command detects Linux distro, Wayland/X11/headless session, container state, required commands, config validity, daemon status, and service installation. In interactive mode it can create/update config, select a microphone, recommend Wayland compositor binding behavior, and show autostart instructions. - **Options:** - `--check`: Run setup checks without changing anything. - `--json`: Print setup check output as JSON. Implies check mode. - `--dry-run`: Show what setup would change without writing. - - `--skip-service`: Do not install or start the systemd user service. + - `--skip-service`: Do not show autostart (exec-once) instructions. - `--help`: Display help for the setup command. ### `uninstall` -Remove the `hyprvox` systemd user service. +Remove the legacy `hyprvox` systemd user service (from installs that predate the single-app topology). - **Options:** - `--help`: Display help for the uninstall command. diff --git a/docs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.md b/docs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.md new file mode 100644 index 0000000..f118459 --- /dev/null +++ b/docs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.md @@ -0,0 +1,93 @@ +# Collapse the daemon and overlay into a single resident Electron app + +Status: accepted — implementation gated on the trigger-latency measurement window (see Consequences) + +Hyprvox currently runs a Bun daemon and an Electron overlay as separate processes +across a unix socket, with **four independent supervision layers, each with its own +terminal state**: systemd's `StartLimitBurst=3`, `supervisor.ts`'s `MAX_RESTARTS`, +`OverlayProcessManager`'s 5-restarts-per-60s, and `IPCClient`'s 10 reconnect +attempts. Any one of them can permanently give up while the others believe the +system is healthy, and none of them can see the others' terminal state. That is the +reliability risk this decides against, and it is a property of the topology rather +than of any bug we can point at. + +We are collapsing to one resident Electron app: main owns the trigger, the state +machine, and the STT WebSockets; a `utilityProcess` owns the merge and quality +pipeline; the renderer keeps its own microphone capture and FFT waveform. Launched +via Hyprland `exec-once`. systemd, `supervisor.ts`, and `OverlayProcessManager` all +go away, and Electron becomes the single supervisor. + +## Considered Options + +**Relocate the transcribe pipeline into Electron main directly.** Rejected on +measurement. The pipeline is portable Node (zero `Bun.*` APIs, three `import.meta.dir` +uses), so relocating it is nearly free — but `decideMerge` is **quadratic** in +transcript length and blocks the event loop for **~38ms at 1,500 words**, which is +~10 minutes of speech and therefore exactly the configured `clipboard.maxDuration` +ceiling. At 3,000 words it blocks ~144ms. Since that stall lands precisely while the +overlay is animating its `processing` state, it would be visible rather than hidden. +Everything else measured is safe (`validateTranscript` 4.8ms at 25kb, +`buildPcm16kMonoWav` 7.6ms on a 19MB buffer), so `utilityProcess` isolation is needed +for the merge specifically, not for the pipeline as a whole. + +**Keep the daemon.** Its process isolation is a real benefit, and we had been +dismissing it. `utilityProcess` preserves that isolation *inside* the single-app +model while still deleting the hand-rolled supervision — so we get the benefit +without the four-layer failure mode. + +**Socket-activate the daemon instead.** Unimplementable: `listen({ fd: 3 })` throws +`EINVAL` on Bun 1.3.3, so `sd_listen_fds`-style activation is not available to us. + +## Explicitly not changing + +- **Audio capture stays double.** `arecord` feeds transcription; the renderer's + `getUserMedia` feeds the waveform. This looks redundant and is not: the renderer + path drives 128 FFT bins at 60fps, where the daemon's IPC path carries 2 smoothed + scalars at 30fps. Collapsing them would visibly degrade the waveform. +- **The CLI stays a one-shot `SIGUSR1`.** It does not become a socket client. +- The overlay window stays permanently mapped and click-through, per ADR-0001. + +## Consequences + +- **This ADR does not supersede ADR-0001 and is not in tension with it.** ADR-0001 + decided *rendering technology* (Electron over GTK); this decides *process + topology*. Electron stays either way. ADR-0001's precedent — that a felt slowness + was a fixable bug rather than an inherent cost, so don't rewrite — still stands, + and is why the next point matters. +- **This decision is justified by the supervision topology alone, not by latency.** + The reported symptom (overlay slow to respond in long sessions, fixed by a restart) + remains **undiagnosed**: measured IPC latency is flat at ~1ms across 63,021 events, + and the daemon's own `starting` timestamp is taken *before* the Deepgram WebSocket + opens, so existing telemetry structurally cannot see the suspected stall. A + trigger-latency measurement window is running to find it. Implementation waits for + that window to close, because shipping this first would destroy the only evidence + that could tell us whether it fixes anything. If the window indicts something this + rewrite would not have fixed, that is worth knowing before the rewrite, not after. + +## Implementation notes (Phase 1, 2026-07-15) + +Phase 1 landed on `feat/single-app-spike`: the supervision stack +(`supervisor.ts`, `overlay-process.ts`, the daemon's IPC server, the overlay's +socket client) is deleted; state flows main → renderer via `webContents.send`; +`DaemonService` is an `EventEmitter` (`state`, `audioLevel`). Two findings made +during implementation are durable constraints: + +- **The overlay depends on XWayland.** Parking the always-mapped window + off-screen (ADR-0001) requires client self-positioning, which native Wayland + forbids. The app pins `--ozone-platform=x11`, and the CLI launcher strips + `ELECTRON_OZONE_PLATFORM_HINT` from the spawn environment — that hint is + consumed by Electron before application JS runs, and (verified on Electron + 34) `hint=auto` combined with the x11 pin half-initializes the browser and + no window is ever created. Going native Wayland would mean redesigning + show/hide and was explicitly deferred (user decision, 2026-07-15). +- **`WM_CLASS` comes from the app directory's `package.json` name.** + `bun run build:app` writes `dist/app/package.json` with + `name: "hyprvox-overlay"` so the user's Hyprland window rules keep matching. + Launching the bare bundle file yields class `Electron` and no rule matches. + +The CLI's `soniox-toggle` verb kept a socket home: a minimal command socket in +the app main (`src/app/command-server.ts`) on the same `daemon.sock` path, +which also serves as the single-instance guard. SIGUSR1/SIGUSR2 semantics are +unchanged. `hyprvox install` no longer writes a systemd unit; it prints the +`exec-once` line, and crash recovery is Electron plus lazy respawn on the next +`hyprvox toggle`.