From 0b8dd2b46fc4c7a0bfbc34b3053dec210ebdbb2f Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 04:53:49 -0600 Subject: [PATCH 01/49] feat: add platform abstraction layer for Windows support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/main/platform/ — a thin interface layer that separates macOS-specific native code from the rest of the app. - interface.ts defines PlatformCapabilities (microphone, audio probe, TTS backend, hotkey monitor, snippet expander, color picker) - darwin.ts macOS implementation, self-contained (mirrors logic currently inline in main.ts so main.ts can migrate incrementally) - windows.ts Windows stubs — every method returns a safe value so the app runs without crashing; real implementations follow in later PRs - index.ts exports `platform` (darwin on macOS, windows on win32) main.ts is untouched — no behaviour changes on macOS. --- src/main/platform/darwin.ts | 182 +++++++++++++++++++++++++++++++++ src/main/platform/index.ts | 23 +++++ src/main/platform/interface.ts | 87 ++++++++++++++++ src/main/platform/windows.ts | 67 ++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 src/main/platform/darwin.ts create mode 100644 src/main/platform/index.ts create mode 100644 src/main/platform/interface.ts create mode 100644 src/main/platform/windows.ts diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts new file mode 100644 index 00000000..ccc4c255 --- /dev/null +++ b/src/main/platform/darwin.ts @@ -0,0 +1,182 @@ +/** + * platform/darwin.ts + * + * macOS implementation of PlatformCapabilities. + * Logic is self-contained here so main.ts can migrate to it incrementally. + */ + +import * as path from 'path'; +import type { ChildProcess } from 'child_process'; +import type { + PlatformCapabilities, + MicrophoneAccessStatus, + MicrophonePermissionResult, + LocalSpeakBackend, + HotkeyModifiers, +} from './interface'; + +const electron = require('electron'); +const { app, systemPreferences } = electron; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getNativeBinaryPath(name: string): string { + const base = path.join(__dirname, '..', 'native', name); + if (app.isPackaged) { + return base.replace('app.asar', 'app.asar.unpacked'); + } + return base; +} + +// ── Implementation ──────────────────────────────────────────────────────────── + +export const darwin: PlatformCapabilities = { + readMicrophoneAccessStatus(): MicrophoneAccessStatus { + try { + const raw = String( + systemPreferences.getMediaAccessStatus('microphone') || '' + ).toLowerCase(); + if ( + raw === 'granted' || + raw === 'denied' || + raw === 'restricted' || + raw === 'not-determined' + ) { + return raw; + } + return 'unknown'; + } catch { + return 'unknown'; + } + }, + + async requestMicrophoneAccessViaNative( + prompt: boolean + ): Promise { + const fs = require('fs'); + const { spawn } = require('child_process'); + + const binaryPath = getNativeBinaryPath('microphone-access'); + if (!fs.existsSync(binaryPath)) return null; + + return new Promise((resolve) => { + const args = prompt ? ['--prompt'] : []; + const proc = spawn(binaryPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + + proc.stdout.on('data', (chunk: Buffer | string) => { + stdout += String(chunk || ''); + }); + proc.on('error', () => resolve(null)); + proc.on('close', () => { + try { + const lines = stdout.split('\n').filter(Boolean); + for (const line of lines) { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === 'object') { + resolve(parsed as MicrophonePermissionResult); + return; + } + } + } catch {} + resolve(null); + }); + }); + }, + + probeAudioDurationMs(audioPath: string): number | null { + const target = String(audioPath || '').trim(); + if (!target) return null; + try { + const { spawnSync } = require('child_process'); + const result = spawnSync('/usr/bin/afinfo', [target], { + encoding: 'utf-8', + timeout: 4000, + }); + const output = `${String(result?.stdout || '')}\n${String(result?.stderr || '')}`; + const secMatch = /estimated duration:\s*([0-9]+(?:\.[0-9]+)?)\s*sec/i.exec(output); + const seconds = secMatch ? Number(secMatch[1]) : NaN; + if (Number.isFinite(seconds) && seconds > 0) { + return Math.round(seconds * 1000); + } + } catch {} + return null; + }, + + resolveSpeakBackend(): LocalSpeakBackend | null { + try { + const mod = require('node-edge-tts'); + const ctor = mod?.EdgeTTS || mod?.default?.EdgeTTS || mod?.default || mod; + if (typeof ctor === 'function') return 'edge-tts'; + } catch {} + return 'system-say'; + }, + + spawnHotkeyHoldMonitor( + keyCode: number, + modifiers: HotkeyModifiers, + holdMs: number + ): ChildProcess | null { + const fs = require('fs'); + const binaryPath = getNativeBinaryPath('hotkey-hold-monitor'); + if (!fs.existsSync(binaryPath)) return null; + + const { spawn } = require('child_process'); + try { + return spawn( + binaryPath, + [ + String(keyCode), + modifiers.cmd ? '1' : '0', + modifiers.ctrl ? '1' : '0', + modifiers.shift ? '1' : '0', + modifiers.alt ? '1' : '0', + String(holdMs), + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + } catch { + return null; + } + }, + + spawnSnippetExpander(keywords: string[]): ChildProcess | null { + const fs = require('fs'); + const expanderPath = getNativeBinaryPath('snippet-expander'); + if (!fs.existsSync(expanderPath)) return null; + + const { spawn } = require('child_process'); + try { + return spawn(expanderPath, [JSON.stringify(keywords)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + return null; + } + }, + + async pickColor(): Promise { + const fs = require('fs'); + const binaryPath = getNativeBinaryPath('color-picker'); + if (!fs.existsSync(binaryPath)) return null; + + return new Promise((resolve) => { + const { spawn } = require('child_process'); + const proc = spawn(binaryPath, [], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + + proc.stdout.on('data', (chunk: Buffer | string) => { + stdout += String(chunk || ''); + }); + proc.on('error', () => resolve(null)); + proc.on('close', () => { + const color = stdout.trim(); + resolve(color || null); + }); + }); + }, +}; diff --git a/src/main/platform/index.ts b/src/main/platform/index.ts new file mode 100644 index 00000000..3f95af7a --- /dev/null +++ b/src/main/platform/index.ts @@ -0,0 +1,23 @@ +/** + * platform/index.ts + * + * Exports the correct PlatformCapabilities implementation for the current OS. + * Import from here — never from darwin.ts or windows.ts directly. + * + * import { platform } from './platform'; + * const status = platform.readMicrophoneAccessStatus(); + */ + +export type { + PlatformCapabilities, + MicrophoneAccessStatus, + MicrophonePermissionResult, + LocalSpeakBackend, + HotkeyModifiers, +} from './interface'; + +import { darwin } from './darwin'; +import { windows } from './windows'; + +export const platform = + process.platform === 'win32' ? windows : darwin; diff --git a/src/main/platform/interface.ts b/src/main/platform/interface.ts new file mode 100644 index 00000000..90aa07ac --- /dev/null +++ b/src/main/platform/interface.ts @@ -0,0 +1,87 @@ +/** + * platform/interface.ts + * + * Contract every platform implementation must satisfy. + * main.ts imports from platform/index.ts — never from darwin.ts or windows.ts directly. + */ + +import type { ChildProcess } from 'child_process'; + +// ── Shared types ───────────────────────────────────────────────────────────── + +export type MicrophoneAccessStatus = + | 'granted' + | 'denied' + | 'restricted' + | 'not-determined' + | 'unknown'; + +export interface MicrophonePermissionResult { + granted: boolean; + status: MicrophoneAccessStatus; + requested?: boolean; + error?: string; +} + +/** Backends available for local (offline) text-to-speech. */ +export type LocalSpeakBackend = 'edge-tts' | 'system-say'; + +export interface HotkeyModifiers { + cmd: boolean; + ctrl: boolean; + shift: boolean; + alt: boolean; +} + +// ── Platform interface ──────────────────────────────────────────────────────── + +export interface PlatformCapabilities { + /** + * Read the current microphone permission status without prompting the user. + * On platforms that have no permission model (Windows), returns 'granted'. + */ + readMicrophoneAccessStatus(): MicrophoneAccessStatus; + + /** + * Invoke the native helper to request (or probe) microphone access. + * Returns null on platforms where the helper is unavailable. + */ + requestMicrophoneAccessViaNative( + prompt: boolean + ): Promise; + + /** + * Use a platform audio inspection tool to measure the duration of an audio + * file. Returns null when the tool is unavailable (all non-macOS platforms). + */ + probeAudioDurationMs(audioPath: string): number | null; + + /** + * Resolve which local speech backend to use. + * 'system-say' is macOS-only; 'edge-tts' works everywhere. + * Returns null when neither is available. + */ + resolveSpeakBackend(): LocalSpeakBackend | null; + + /** + * Spawn the native hotkey-hold monitor process. + * Returns null on platforms where the binary is unavailable. + */ + spawnHotkeyHoldMonitor( + keyCode: number, + modifiers: HotkeyModifiers, + holdMs: number + ): ChildProcess | null; + + /** + * Spawn the native snippet-expander process with the given keyword list. + * Returns null on platforms where the binary is unavailable. + */ + spawnSnippetExpander(keywords: string[]): ChildProcess | null; + + /** + * Open the platform color-picker and resolve with the picked hex color, + * or null if the user cancelled or the feature is unavailable. + */ + pickColor(): Promise; +} diff --git a/src/main/platform/windows.ts b/src/main/platform/windows.ts new file mode 100644 index 00000000..96bb4f99 --- /dev/null +++ b/src/main/platform/windows.ts @@ -0,0 +1,67 @@ +/** + * platform/windows.ts + * + * Windows stubs for PlatformCapabilities. + * Every method returns a safe value so the app runs without crashing. + * Real Windows implementations will replace these stubs in follow-up PRs. + */ + +import type { + PlatformCapabilities, + MicrophoneAccessStatus, + MicrophonePermissionResult, + LocalSpeakBackend, + HotkeyModifiers, +} from './interface'; + +export const windows: PlatformCapabilities = { + readMicrophoneAccessStatus(): MicrophoneAccessStatus { + // Windows manages microphone access at the OS level; Electron can call + // getUserMedia directly. Return 'granted' so the app doesn't block the user. + return 'granted'; + }, + + async requestMicrophoneAccessViaNative( + _prompt: boolean + ): Promise { + // No native Swift helper on Windows. The renderer uses getUserMedia instead. + return null; + }, + + probeAudioDurationMs(_audioPath: string): number | null { + // afinfo is macOS-only. Will be replaced with a cross-platform probe (ffprobe + // or the Web Audio API duration) in a follow-up PR. + return null; + }, + + resolveSpeakBackend(): LocalSpeakBackend | null { + // 'system-say' is macOS-only. Try edge-tts (works on Windows). + try { + const mod = require('node-edge-tts'); + const ctor = mod?.EdgeTTS || mod?.default?.EdgeTTS || mod?.default || mod; + if (typeof ctor === 'function') return 'edge-tts'; + } catch {} + return null; + }, + + spawnHotkeyHoldMonitor( + _keyCode: number, + _modifiers: HotkeyModifiers, + _holdMs: number + ) { + // Hold-to-talk requires a low-level keyboard hook. Windows implementation + // (Win32 SetWindowsHookEx / RegisterHotKey) will be added in a follow-up PR. + return null; + }, + + spawnSnippetExpander(_keywords: string[]) { + // Snippet expansion requires a system-wide keyboard hook. Windows + // implementation will be added in a follow-up PR. + return null; + }, + + async pickColor(): Promise { + // Will use the Win32 ChooseColor dialog or a JS color picker in a follow-up PR. + return null; + }, +}; From 09747a5417c2e8bb35285eeb1689231bee1df0b9 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 04:58:23 -0600 Subject: [PATCH 02/49] refactor: migrate simple platform functions to use platform abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces inline macOS-guarded implementations in main.ts with calls to the platform layer (platform/darwin.ts on macOS, platform/windows.ts stubs on Windows): - readMicrophoneAccessStatus() → platform.readMicrophoneAccessStatus() - probeAudioDurationMs() → platform.probeAudioDurationMs() - resolveLocalSpeakBackend() → platform.resolveSpeakBackend() - native-pick-color IPC handler → platform.pickColor() Types MicrophoneAccessStatus, MicrophonePermissionResult, LocalSpeakBackend are now imported from platform/interface.ts instead of being redeclared. No behaviour change on macOS. On Windows the app no longer crashes on these code paths — each returns a safe stub value. --- src/main/main.ts | 78 ++++------------------------------ src/main/platform/darwin.ts | 31 ++++++++------ src/main/platform/interface.ts | 3 +- 3 files changed, 29 insertions(+), 83 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 094aa4a3..5c377a30 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -60,6 +60,8 @@ import { importSnippetsFromFile, exportSnippetsToFile, } from './snippet-store'; +import { platform } from './platform'; +import type { MicrophoneAccessStatus, MicrophonePermissionResult, LocalSpeakBackend } from './platform'; const electron = require('electron'); const { app, BrowserWindow, globalShortcut, ipcMain, screen, shell, Menu, Tray, nativeImage, protocol, net, dialog, systemPreferences, clipboard: systemClipboard } = electron; @@ -312,7 +314,7 @@ let fnSpeakToggleWatcherEnabled = false; let fnWatcherOnboardingOverride = false; let fnSpeakToggleLastPressedAt = 0; let fnSpeakToggleIsPressed = false; -type LocalSpeakBackend = 'edge-tts' | 'system-say'; +// LocalSpeakBackend imported from './platform' let edgeTtsConstructorResolved = false; let edgeTtsConstructor: any | null = null; let edgeTtsConstructorError = ''; @@ -448,14 +450,7 @@ type OnboardingPermissionResult = { error?: string; }; -type MicrophoneAccessStatus = 'granted' | 'denied' | 'restricted' | 'not-determined' | 'unknown'; -type MicrophonePermissionResult = { - granted: boolean; - requested: boolean; - status: MicrophoneAccessStatus; - canPrompt: boolean; - error?: string; -}; +// MicrophoneAccessStatus and MicrophonePermissionResult imported from './platform' function describeMicrophoneStatus(status: MicrophoneAccessStatus): string { if (status === 'denied') { @@ -471,21 +466,7 @@ function describeMicrophoneStatus(status: MicrophoneAccessStatus): string { } function readMicrophoneAccessStatus(): MicrophoneAccessStatus { - if (process.platform !== 'darwin') return 'granted'; - try { - const raw = String(systemPreferences.getMediaAccessStatus('microphone') || '').toLowerCase(); - if ( - raw === 'granted' || - raw === 'denied' || - raw === 'restricted' || - raw === 'not-determined' - ) { - return raw; - } - return 'unknown'; - } catch { - return 'unknown'; - } + return platform.readMicrophoneAccessStatus(); } async function requestMicrophoneAccessViaNative(prompt: boolean): Promise { @@ -927,23 +908,7 @@ function parseCueTimeMs(value: any): number { } function probeAudioDurationMs(audioPath: string): number | null { - const target = String(audioPath || '').trim(); - if (!target) return null; - if (process.platform !== 'darwin') return null; - try { - const { spawnSync } = require('child_process'); - const result = spawnSync('/usr/bin/afinfo', [target], { - encoding: 'utf-8', - timeout: 4000, - }); - const output = `${String(result?.stdout || '')}\n${String(result?.stderr || '')}`; - const secMatch = /estimated duration:\s*([0-9]+(?:\.[0-9]+)?)\s*sec/i.exec(output); - const seconds = secMatch ? Number(secMatch[1]) : NaN; - if (Number.isFinite(seconds) && seconds > 0) { - return Math.round(seconds * 1000); - } - } catch {} - return null; + return platform.probeAudioDurationMs(audioPath); } function normalizePermissionStatus(raw: any): MicrophoneAccessStatus { @@ -983,9 +948,7 @@ function resolveEdgeTtsConstructor(): any | null { } function resolveLocalSpeakBackend(): LocalSpeakBackend | null { - if (resolveEdgeTtsConstructor()) return 'edge-tts'; - if (process.platform === 'darwin') return 'system-say'; - return null; + return platform.resolveSpeakBackend(); } async function synthesizeWithEdgeTts(opts: { @@ -7621,34 +7584,9 @@ return appURL's |path|() as text`, // ─── IPC: Native Color Picker ────────────────────────────────── ipcMain.handle('native-pick-color', async () => { - const { execFile } = require('child_process'); - const colorPickerPath = getNativeBinaryPath('color-picker'); - // Keep the launcher open while the native picker is focused. suppressBlurHide = true; - const pickedColor = await new Promise((resolve) => { - execFile(colorPickerPath, (error: any, stdout: string) => { - if (error) { - console.error('Color picker failed:', error); - resolve(null); - return; - } - - const trimmed = stdout.trim(); - if (trimmed === 'null' || !trimmed) { - resolve(null); - return; - } - - try { - const color = JSON.parse(trimmed); - resolve(color); - } catch (e) { - console.error('Failed to parse color picker output:', e); - resolve(null); - } - }); - }); + const pickedColor = await platform.pickColor(); suppressBlurHide = false; return pickedColor; }); diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts index ccc4c255..f51ebd27 100644 --- a/src/main/platform/darwin.ts +++ b/src/main/platform/darwin.ts @@ -15,8 +15,7 @@ import type { HotkeyModifiers, } from './interface'; -const electron = require('electron'); -const { app, systemPreferences } = electron; +import { app, systemPreferences } from 'electron'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -71,16 +70,24 @@ export const darwin: PlatformCapabilities = { }); proc.on('error', () => resolve(null)); proc.on('close', () => { - try { - const lines = stdout.split('\n').filter(Boolean); - for (const line of lines) { - const parsed = JSON.parse(line); - if (parsed && typeof parsed === 'object') { - resolve(parsed as MicrophonePermissionResult); - return; - } - } - } catch {} + const lines = stdout.split('\n').map((l: string) => l.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const payload = JSON.parse(lines[i]); + const raw = String(payload?.status || '').toLowerCase().replace(/_/g, '-'); + const status: MicrophoneAccessStatus = + raw === 'authorized' ? 'granted' : + raw === 'notdetermined' ? 'not-determined' : + (['granted', 'denied', 'restricted', 'not-determined'].includes(raw) ? raw as MicrophoneAccessStatus : 'unknown'); + const granted = Boolean(payload?.granted) || status === 'granted'; + const requested = Boolean(payload?.requested); + const canPrompt = typeof payload?.canPrompt === 'boolean' + ? Boolean(payload.canPrompt) + : status === 'not-determined' || status === 'unknown'; + resolve({ granted, requested, status, canPrompt, error: payload?.error }); + return; + } catch {} + } resolve(null); }); }); diff --git a/src/main/platform/interface.ts b/src/main/platform/interface.ts index 90aa07ac..7a91e5d4 100644 --- a/src/main/platform/interface.ts +++ b/src/main/platform/interface.ts @@ -18,8 +18,9 @@ export type MicrophoneAccessStatus = export interface MicrophonePermissionResult { granted: boolean; + requested: boolean; status: MicrophoneAccessStatus; - requested?: boolean; + canPrompt: boolean; error?: string; } From 26c5afc8e9cb0d8c6695075278923807083e97d0 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:01:07 -0600 Subject: [PATCH 03/49] refactor: migrate snippet expander and hotkey hold monitor to platform layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refreshSnippetExpander: binary resolution + spawn moved to platform.spawnSnippetExpander() - startWhisperHoldWatcher: removes ensureWhisperHoldWatcherBinary(), uses platform.spawnHotkeyHoldMonitor() - startFnSpeakToggleWatcher: same — uses platform.spawnHotkeyHoldMonitor() - HotkeyModifiers: added fn field to match Swift binary arg order (keyCode cmd ctrl alt shift fn) - darwin.ts: folds in compile-on-demand logic from ensureWhisperHoldWatcherBinary Event handlers remain in main.ts since they reference main.ts-local state. No behaviour change on macOS. On Windows both features return null and are skipped gracefully. --- src/main/main.ts | 94 +++------------------------------- src/main/platform/darwin.ts | 39 +++++++++++--- src/main/platform/interface.ts | 6 +-- src/main/platform/windows.ts | 3 +- 4 files changed, 43 insertions(+), 99 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 5c377a30..33c2fcc1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1846,22 +1846,8 @@ function startFnSpeakToggleWatcher(): void { if (fnSpeakToggleWatcherProcess || !fnSpeakToggleWatcherEnabled) return; const config = parseHoldShortcutConfig('Fn'); if (!config) return; - const binaryPath = ensureWhisperHoldWatcherBinary(); - if (!binaryPath) return; - - const { spawn } = require('child_process'); - fnSpeakToggleWatcherProcess = spawn( - binaryPath, - [ - String(config.keyCode), - config.cmd ? '1' : '0', - config.ctrl ? '1' : '0', - config.alt ? '1' : '0', - config.shift ? '1' : '0', - config.fn ? '1' : '0', - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ); + fnSpeakToggleWatcherProcess = platform.spawnHotkeyHoldMonitor(config.keyCode, config); + if (!fnSpeakToggleWatcherProcess) return; fnSpeakToggleWatcherStdoutBuffer = ''; fnSpeakToggleWatcherProcess.stdout.on('data', (chunk: Buffer | string) => { @@ -1958,38 +1944,6 @@ function syncFnSpeakToggleWatcher(hotkeys: Record): void { startFnSpeakToggleWatcher(); } -function ensureWhisperHoldWatcherBinary(): string | null { - const fs = require('fs'); - const binaryPath = getNativeBinaryPath('hotkey-hold-monitor'); - if (fs.existsSync(binaryPath)) return binaryPath; - try { - const { execFileSync } = require('child_process'); - const sourceCandidates = [ - path.join(app.getAppPath(), 'src', 'native', 'hotkey-hold-monitor.swift'), - path.join(process.cwd(), 'src', 'native', 'hotkey-hold-monitor.swift'), - path.join(__dirname, '..', '..', 'src', 'native', 'hotkey-hold-monitor.swift'), - ]; - const sourcePath = sourceCandidates.find((candidate) => fs.existsSync(candidate)); - if (!sourcePath) { - console.warn('[Whisper][hold] Source file not found for hotkey-hold-monitor.swift'); - return null; - } - fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); - execFileSync('swiftc', [ - '-O', - '-o', binaryPath, - sourcePath, - '-framework', 'CoreGraphics', - '-framework', 'AppKit', - '-framework', 'Carbon', - ]); - return binaryPath; - } catch (error) { - console.warn('[Whisper][hold] Failed to compile hotkey hold monitor:', error); - return null; - } -} - function startWhisperHoldWatcher(shortcut: string, holdSeq: number): void { if (whisperHoldWatcherProcess) return; const config = parseHoldShortcutConfig(shortcut); @@ -1997,25 +1951,11 @@ function startWhisperHoldWatcher(shortcut: string, holdSeq: number): void { console.warn('[Whisper][hold] Unsupported shortcut for hold-to-talk:', shortcut); return; } - const binaryPath = ensureWhisperHoldWatcherBinary(); - if (!binaryPath) { - console.warn('[Whisper][hold] Hold monitor binary unavailable'); + whisperHoldWatcherProcess = platform.spawnHotkeyHoldMonitor(config.keyCode, config); + if (!whisperHoldWatcherProcess) { + console.warn('[Whisper][hold] Hold monitor unavailable on this platform'); return; } - - const { spawn } = require('child_process'); - whisperHoldWatcherProcess = spawn( - binaryPath, - [ - String(config.keyCode), - config.cmd ? '1' : '0', - config.ctrl ? '1' : '0', - config.alt ? '1' : '0', - config.shift ? '1' : '0', - config.fn ? '1' : '0', - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ); whisperHoldWatcherSeq = holdSeq; whisperHoldWatcherStdoutBuffer = ''; @@ -3271,7 +3211,6 @@ function stopSnippetExpander(): void { } function refreshSnippetExpander(): void { - if (process.platform !== 'darwin') return; stopSnippetExpander(); const keywords = getAllSnippets() @@ -3280,28 +3219,9 @@ function refreshSnippetExpander(): void { if (keywords.length === 0) return; - const expanderPath = getNativeBinaryPath('snippet-expander'); - const fs = require('fs'); - if (!fs.existsSync(expanderPath)) { - try { - const { execFileSync } = require('child_process'); - const sourcePath = path.join(app.getAppPath(), 'src', 'native', 'snippet-expander.swift'); - execFileSync('swiftc', ['-O', '-o', expanderPath, sourcePath, '-framework', 'AppKit']); - } catch (error) { - console.warn('[SnippetExpander] Native helper not found and compile failed:', error); - return; - } - } + snippetExpanderProcess = platform.spawnSnippetExpander(keywords); + if (!snippetExpanderProcess) return; - const { spawn } = require('child_process'); - try { - snippetExpanderProcess = spawn(expanderPath, [JSON.stringify(keywords)], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - console.warn('[SnippetExpander] Failed to spawn native helper:', error); - return; - } console.log(`[SnippetExpander] Started with ${keywords.length} keyword(s)`); snippetExpanderProcess.stdout.on('data', (chunk: Buffer | string) => { diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts index f51ebd27..5071e03b 100644 --- a/src/main/platform/darwin.ts +++ b/src/main/platform/darwin.ts @@ -123,24 +123,49 @@ export const darwin: PlatformCapabilities = { spawnHotkeyHoldMonitor( keyCode: number, - modifiers: HotkeyModifiers, - holdMs: number + modifiers: HotkeyModifiers ): ChildProcess | null { const fs = require('fs'); - const binaryPath = getNativeBinaryPath('hotkey-hold-monitor'); - if (!fs.existsSync(binaryPath)) return null; + const { execFileSync, spawn } = require('child_process'); + + let binaryPath = getNativeBinaryPath('hotkey-hold-monitor'); + if (!fs.existsSync(binaryPath)) { + // Compile on demand (dev mode — packaged builds include the pre-built binary). + const sourceCandidates = [ + path.join(app.getAppPath(), 'src', 'native', 'hotkey-hold-monitor.swift'), + path.join(process.cwd(), 'src', 'native', 'hotkey-hold-monitor.swift'), + path.join(__dirname, '..', '..', 'src', 'native', 'hotkey-hold-monitor.swift'), + ]; + const sourcePath = sourceCandidates.find((c) => fs.existsSync(c)); + if (!sourcePath) { + console.warn('[Whisper][hold] Source file not found for hotkey-hold-monitor.swift'); + return null; + } + try { + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + execFileSync('swiftc', [ + '-O', '-o', binaryPath, sourcePath, + '-framework', 'CoreGraphics', + '-framework', 'AppKit', + '-framework', 'Carbon', + ]); + } catch (error) { + console.warn('[Whisper][hold] Failed to compile hotkey hold monitor:', error); + return null; + } + } - const { spawn } = require('child_process'); try { + // Args match the Swift binary's expected order: keyCode cmd ctrl alt shift fn return spawn( binaryPath, [ String(keyCode), modifiers.cmd ? '1' : '0', modifiers.ctrl ? '1' : '0', - modifiers.shift ? '1' : '0', modifiers.alt ? '1' : '0', - String(holdMs), + modifiers.shift ? '1' : '0', + modifiers.fn ? '1' : '0', ], { stdio: ['ignore', 'pipe', 'pipe'] } ); diff --git a/src/main/platform/interface.ts b/src/main/platform/interface.ts index 7a91e5d4..d6ac857a 100644 --- a/src/main/platform/interface.ts +++ b/src/main/platform/interface.ts @@ -30,8 +30,9 @@ export type LocalSpeakBackend = 'edge-tts' | 'system-say'; export interface HotkeyModifiers { cmd: boolean; ctrl: boolean; - shift: boolean; alt: boolean; + shift: boolean; + fn: boolean; } // ── Platform interface ──────────────────────────────────────────────────────── @@ -70,8 +71,7 @@ export interface PlatformCapabilities { */ spawnHotkeyHoldMonitor( keyCode: number, - modifiers: HotkeyModifiers, - holdMs: number + modifiers: HotkeyModifiers ): ChildProcess | null; /** diff --git a/src/main/platform/windows.ts b/src/main/platform/windows.ts index 96bb4f99..bd23a577 100644 --- a/src/main/platform/windows.ts +++ b/src/main/platform/windows.ts @@ -46,8 +46,7 @@ export const windows: PlatformCapabilities = { spawnHotkeyHoldMonitor( _keyCode: number, - _modifiers: HotkeyModifiers, - _holdMs: number + _modifiers: HotkeyModifiers ) { // Hold-to-talk requires a low-level keyboard hook. Windows implementation // (Win32 SetWindowsHookEx / RegisterHotKey) will be added in a follow-up PR. From 6648deee2e5e0a4dcc8ccf5aedbb0f138f4dd5dc Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:02:31 -0600 Subject: [PATCH 04/49] refactor: migrate requestMicrophoneAccessViaNative to platform layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requestMicrophoneAccessViaNative() → platform.requestMicrophoneAccessViaNative() - ensureMicrophoneAccess(): remove process.platform guard — readMicrophoneAccessStatus() already returns 'granted' on Windows via the platform layer, so the guard was redundant All platform-specific native code in main.ts is now behind platform.* --- src/main/main.ts | 67 ++---------------------------------------------- 1 file changed, 2 insertions(+), 65 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 33c2fcc1..79384b9d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -470,74 +470,11 @@ function readMicrophoneAccessStatus(): MicrophoneAccessStatus { } async function requestMicrophoneAccessViaNative(prompt: boolean): Promise { - if (process.platform !== 'darwin') return null; - const fs = require('fs'); - const binaryPath = getNativeBinaryPath('microphone-access'); - if (!fs.existsSync(binaryPath)) return null; - - return await new Promise((resolve) => { - const { spawn } = require('child_process'); - const args = prompt ? ['--prompt'] : []; - const proc = spawn(binaryPath, args, { - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (chunk: Buffer | string) => { - stdout += String(chunk || ''); - }); - proc.stderr.on('data', (chunk: Buffer | string) => { - stderr += String(chunk || ''); - }); - - proc.on('error', () => { - resolve(null); - }); - - proc.on('close', () => { - const lines = stdout - .split('\n') - .map((line: string) => line.trim()) - .filter(Boolean); - for (let i = lines.length - 1; i >= 0; i -= 1) { - try { - const payload = JSON.parse(lines[i]); - const status = normalizePermissionStatus(payload?.status); - const granted = Boolean(payload?.granted) || status === 'granted'; - const requested = Boolean(payload?.requested); - const canPrompt = typeof payload?.canPrompt === 'boolean' - ? Boolean(payload.canPrompt) - : status === 'not-determined' || status === 'unknown'; - const result: MicrophonePermissionResult = { - granted, - requested, - status, - canPrompt, - error: granted - ? undefined - : String(payload?.error || '').trim() || (stderr.trim() || undefined), - }; - resolve(result); - return; - } catch {} - } - resolve(null); - }); - }); + return platform.requestMicrophoneAccessViaNative(prompt); } async function ensureMicrophoneAccess(prompt = true): Promise { - if (process.platform !== 'darwin') { - return { - granted: true, - requested: false, - status: 'granted', - canPrompt: false, - }; - } - - const before = readMicrophoneAccessStatus(); + const before = readMicrophoneAccessStatus(); // returns 'granted' on non-darwin via platform layer if (before === 'granted') { return { granted: true, From edfc66566b154dedb366923bacdf13d9ad501544 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:06:23 -0600 Subject: [PATCH 05/49] feat: add Windows build target, .ico icon, and cross-platform native build script - supercmd.ico: Windows icon with 6 sizes (16/32/48/64/128/256px) - scripts/build-native.js: replaces the inline swiftc chain in package.json; compiles Swift helpers on macOS and exits cleanly (no-op) on Windows - package.json: build:native now calls the script; adds electron-builder win target (nsis installer, x64 + arm64) and nsis config --- package.json | 17 ++++++++++- scripts/build-native.js | 64 ++++++++++++++++++++++++++++++++++++++++ supercmd.ico | Bin 0 -> 175222 bytes 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 scripts/build-native.js create mode 100644 supercmd.ico diff --git a/package.json b/package.json index 40f4fa51..6cab37b4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "npm run build:main && npm run build:renderer && npm run build:native", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", - "build:native": "mkdir -p dist/native && swiftc -O -o dist/native/color-picker src/native/color-picker.swift -framework AppKit && swiftc -O -o dist/native/snippet-expander src/native/snippet-expander.swift -framework AppKit && swiftc -O -o dist/native/hotkey-hold-monitor src/native/hotkey-hold-monitor.swift -framework CoreGraphics -framework AppKit -framework Carbon && swiftc -O -o dist/native/speech-recognizer src/native/speech-recognizer.swift -framework Speech -framework AVFoundation && swiftc -O -o dist/native/microphone-access src/native/microphone-access.swift -framework AVFoundation && swiftc -O -o dist/native/input-monitoring-request src/native/input-monitoring-request.swift -framework CoreGraphics", + "build:native": "node scripts/build-native.js", "postinstall": "electron-builder install-app-deps", "start": "electron .", "package": "npm run build && electron-builder" @@ -96,6 +96,21 @@ "NSSpeechRecognitionUsageDescription": "SuperCmd uses speech recognition for native Whisper dictation." } }, + "win": { + "icon": "supercmd.ico", + "target": [ + { + "target": "nsis", + "arch": ["x64", "arm64"] + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, "linux": { "icon": "supercmd.svg" } diff --git a/scripts/build-native.js b/scripts/build-native.js new file mode 100644 index 00000000..1df9eca1 --- /dev/null +++ b/scripts/build-native.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * scripts/build-native.js + * + * Compiles Swift native helpers on macOS. + * On other platforms this is a no-op — the native features are stubbed out + * in platform/windows.ts and will be replaced with platform-native + * implementations in follow-up work. + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +if (process.platform !== 'darwin') { + console.log('[build-native] Skipping Swift compilation (not macOS).'); + process.exit(0); +} + +const outDir = path.join(__dirname, '..', 'dist', 'native'); +fs.mkdirSync(outDir, { recursive: true }); + +const binaries = [ + { + out: 'color-picker', + src: 'src/native/color-picker.swift', + frameworks: ['AppKit'], + }, + { + out: 'snippet-expander', + src: 'src/native/snippet-expander.swift', + frameworks: ['AppKit'], + }, + { + out: 'hotkey-hold-monitor', + src: 'src/native/hotkey-hold-monitor.swift', + frameworks: ['CoreGraphics', 'AppKit', 'Carbon'], + }, + { + out: 'speech-recognizer', + src: 'src/native/speech-recognizer.swift', + frameworks: ['Speech', 'AVFoundation'], + }, + { + out: 'microphone-access', + src: 'src/native/microphone-access.swift', + frameworks: ['AVFoundation'], + }, + { + out: 'input-monitoring-request', + src: 'src/native/input-monitoring-request.swift', + frameworks: ['CoreGraphics'], + }, +]; + +for (const { out, src, frameworks } of binaries) { + const outPath = path.join(outDir, out); + const frameworkArgs = frameworks.flatMap((f) => ['-framework', f]); + const cmd = ['swiftc', '-O', '-o', outPath, src, ...frameworkArgs].join(' '); + console.log(`[build-native] Compiling ${out}...`); + execSync(cmd, { stdio: 'inherit' }); +} + +console.log('[build-native] Done.'); diff --git a/supercmd.ico b/supercmd.ico new file mode 100644 index 0000000000000000000000000000000000000000..8530e247cd98cf1c4e20da86f43ab2023a49d44f GIT binary patch literal 175222 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 p0a2g1%yHE83!gT-u002ovPDHLkV1ivB!Lk4V literal 0 HcmV?d00001 From 84a75b5a9425309bff06dfef544d2be8e8374da2 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:07:28 -0600 Subject: [PATCH 06/49] ci: add CI workflow with tests (Linux) and Windows build verification - test job: runs unit tests on ubuntu-latest (same as feat/add-test-coverage) - build-windows job: runs on windows-latest, compiles TypeScript + Vite, skips Swift (build:native is a no-op on Windows), then does an unsigned electron-builder dir build to confirm the app packages without errors Triggers on push/PR to main and feat/windows-foundation. --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f96ad933 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main, feat/windows-foundation] + pull_request: + branches: [main, feat/windows-foundation] + +jobs: + # ── Unit tests (Linux, fast) ──────────────────────────────────────────────── + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # Skip postinstall (electron-builder install-app-deps) — not needed for unit tests + - run: npm ci --ignore-scripts + + - run: npm test + + # ── Windows build verification ────────────────────────────────────────────── + build-windows: + name: Windows build + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # Skip postinstall — electron-builder install-app-deps not needed for a + # compile check. The build:native script is a no-op on Windows. + - run: npm ci --ignore-scripts + + - name: Build (main + renderer + native) + run: npm run build + + - name: Package (unsigned dir build — no code signing needed) + run: npx electron-builder --win dir --x64 + env: + # Disable code signing — no cert available in CI + CSC_IDENTITY_AUTO_DISCOVERY: false + WIN_CSC_LINK: "" From 5be6be220ccd4d719be911a79a99cd927bd8648f Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:23:24 -0600 Subject: [PATCH 07/49] fix(ci): wire up vitest and fix TypeScript build on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vitest@^4.0.0 to devDependencies and a `test` script so `npm test` works in the Tests CI job - Regenerate package-lock.json so `npm ci` can install vitest in CI - Create vitest.config.ts (node environment, src/main/__tests__ glob) - Exclude __tests__ / *.test.ts files from tsconfig.main.json so the Windows build job no longer fails with TS2307 (cannot find 'vitest') - Guard icons.test.ts with describe.runIf(darwin) — iconutil is macOS-only and would error on the Linux test runner --- package-lock.json | 1001 +++++++++++++++++++++++++++++- package.json | 2 + src/main/__tests__/icons.test.ts | 3 +- tsconfig.main.json | 3 +- vitest.config.ts | 8 + 5 files changed, 987 insertions(+), 30 deletions(-) create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 892d262b..25c291b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.0", + "version": "1.0.2", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -34,6 +34,7 @@ "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.0.11", + "vitest": "^4.0.0", "wait-on": "^7.2.0" } }, @@ -2661,6 +2662,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2741,6 +2749,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2750,6 +2769,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2892,6 +2918,90 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3217,6 +3327,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3643,6 +3763,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4826,6 +4956,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4933,6 +5070,26 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -6138,6 +6295,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -6477,6 +6644,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6611,6 +6789,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -7365,6 +7550,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7502,6 +7694,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -7511,6 +7710,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7801,6 +8007,23 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7846,6 +8069,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -8520,39 +8753,751 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.1" + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "wait-on": "bin/wait-on" + "vitest": "vitest.mjs" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, - "bin": { - "node-which": "bin/node-which" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 8" + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" } }, "node_modules/widest-line": { diff --git a/package.json b/package.json index 6cab37b4..689c8b83 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", "build:native": "node scripts/build-native.js", + "test": "vitest run", "postinstall": "electron-builder install-app-deps", "start": "electron .", "package": "npm run build && electron-builder" @@ -22,6 +23,7 @@ "license": "ISC", "devDependencies": { "@types/node": "^20.11.5", + "vitest": "^4.0.0", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", diff --git a/src/main/__tests__/icons.test.ts b/src/main/__tests__/icons.test.ts index 6e48f615..d117c96a 100644 --- a/src/main/__tests__/icons.test.ts +++ b/src/main/__tests__/icons.test.ts @@ -29,7 +29,8 @@ const REQUIRED_SIZES = [ 'icon_512x512@2x.png', ]; -describe('supercmd.icns', () => { +// iconutil is macOS-only — skip this suite on Linux/Windows CI +describe.runIf(process.platform === 'darwin')('supercmd.icns', () => { it('exists', () => { expect(fs.existsSync(ICNS), `${ICNS} not found`).toBe(true); }); diff --git a/tsconfig.main.json b/tsconfig.main.json index 01f681b3..f1d6bd2b 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -7,7 +7,8 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src/main/**/*"] + "include": ["src/main/**/*"], + "exclude": ["src/main/**/__tests__/**", "src/main/**/*.test.ts", "src/main/**/*.spec.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..16efc739 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/main/__tests__/**/*.test.ts'], + }, +}); From 79ff1be3627113ae34d6b5b92bd98be2f93a4748 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:29:19 -0600 Subject: [PATCH 08/49] fix(ci): remove WIN_CSC_LINK="" to fix Windows packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting WIN_CSC_LINK to an empty string causes electron-builder to resolve it as a relative path (→ cwd), which fails with "not a file". Leave it unset — CSC_IDENTITY_AUTO_DISCOVERY=false is sufficient to skip signing when no certificate is available. --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f96ad933..0cbb55dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: - name: Package (unsigned dir build — no code signing needed) run: npx electron-builder --win dir --x64 env: - # Disable code signing — no cert available in CI + # Disable code signing — no cert available in CI. + # Do NOT set WIN_CSC_LINK to an empty string; electron-builder resolves + # empty strings as relative paths (→ cwd), which causes a "not a file" + # error. Leave it unset and rely on CSC_IDENTITY_AUTO_DISCOVERY=false. CSC_IDENTITY_AUTO_DISCOVERY: false - WIN_CSC_LINK: "" From 880d8c549292c2527f80146233ef35127086a670 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 05:46:57 -0600 Subject: [PATCH 09/49] feat(windows): color picker, hotkey monitor, and CI artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color picker - Implements platform.pickColor() using a small Electron BrowserWindow with an inlined UI. Returns the hex string on OK, null on Cancel/Escape. No native binary required. Hotkey hold monitor - Adds src/native/hotkey-hold-monitor.c — a Win32 WH_KEYBOARD_LL hook that follows the exact same JSON-over-stdout protocol as the macOS Swift binary ({"ready"}, {"pressed"}, {"released"}). - Maps macOS CGKeyCodes (used by parseHoldShortcutConfig) to Windows VKs. Command and Fn args are accepted but ignored (no Win32 equivalent). - Updates scripts/build-native.js to compile it with gcc (MinGW) on Windows; existing macOS Swift compilation is unchanged. - Wires platform/windows.ts to spawn hotkey-hold-monitor.exe from dist/native/, falling back gracefully if the binary is absent. CI artifact - Uploads out/win-unpacked/ as "SuperCmd-win-x64-portable" (14-day retention) so testers can download, extract, and run SuperCmd.exe directly from any passing CI run without a full NSIS installer. - Updates the stale comment that said build:native was a no-op on Windows. --- .github/workflows/ci.yml | 13 ++- scripts/build-native.js | 125 +++++++++++++-------- src/main/platform/windows.ts | 158 ++++++++++++++++++++++++--- src/native/hotkey-hold-monitor.c | 182 +++++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 65 deletions(-) create mode 100644 src/native/hotkey-hold-monitor.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cbb55dd..31f36cec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: node-version: 20 cache: npm - # Skip postinstall — electron-builder install-app-deps not needed for a - # compile check. The build:native script is a no-op on Windows. + # Skip postinstall — electron-builder install-app-deps not needed here. + # build:native now compiles hotkey-hold-monitor.exe via gcc (MinGW). - run: npm ci --ignore-scripts - name: Build (main + renderer + native) @@ -51,3 +51,12 @@ jobs: # empty strings as relative paths (→ cwd), which causes a "not a file" # error. Leave it unset and rely on CSC_IDENTITY_AUTO_DISCOVERY=false. CSC_IDENTITY_AUTO_DISCOVERY: false + + - name: Upload portable Windows build + uses: actions/upload-artifact@v4 + with: + name: SuperCmd-win-x64-portable + # Upload the unpacked dir — extract and run SuperCmd.exe to test + # without needing a full NSIS installer. + path: out/win-unpacked/ + retention-days: 14 diff --git a/scripts/build-native.js b/scripts/build-native.js index 1df9eca1..ef231858 100644 --- a/scripts/build-native.js +++ b/scripts/build-native.js @@ -2,63 +2,94 @@ /** * scripts/build-native.js * - * Compiles Swift native helpers on macOS. - * On other platforms this is a no-op — the native features are stubbed out - * in platform/windows.ts and will be replaced with platform-native - * implementations in follow-up work. + * Compiles platform-native helpers. + * + * macOS — Swift binaries (requires swiftc) + * Windows — C binaries (requires gcc from MinGW-w64, available on the + * GitHub Actions windows-latest runner and in most + * Node.js-on-Windows developer setups via Git for + * Windows / Scoop / Chocolatey) + * Other — no-op */ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -if (process.platform !== 'darwin') { - console.log('[build-native] Skipping Swift compilation (not macOS).'); +const outDir = path.join(__dirname, '..', 'dist', 'native'); +fs.mkdirSync(outDir, { recursive: true }); + +// ── macOS ────────────────────────────────────────────────────────────────── + +if (process.platform === 'darwin') { + const binaries = [ + { + out: 'color-picker', + src: 'src/native/color-picker.swift', + frameworks: ['AppKit'], + }, + { + out: 'snippet-expander', + src: 'src/native/snippet-expander.swift', + frameworks: ['AppKit'], + }, + { + out: 'hotkey-hold-monitor', + src: 'src/native/hotkey-hold-monitor.swift', + frameworks: ['CoreGraphics', 'AppKit', 'Carbon'], + }, + { + out: 'speech-recognizer', + src: 'src/native/speech-recognizer.swift', + frameworks: ['Speech', 'AVFoundation'], + }, + { + out: 'microphone-access', + src: 'src/native/microphone-access.swift', + frameworks: ['AVFoundation'], + }, + { + out: 'input-monitoring-request', + src: 'src/native/input-monitoring-request.swift', + frameworks: ['CoreGraphics'], + }, + ]; + + for (const { out, src, frameworks } of binaries) { + const outPath = path.join(outDir, out); + const frameworkArgs = frameworks.flatMap((f) => ['-framework', f]); + const cmd = ['swiftc', '-O', '-o', outPath, src, ...frameworkArgs].join(' '); + console.log(`[build-native] Compiling ${out}...`); + execSync(cmd, { stdio: 'inherit' }); + } + + console.log('[build-native] Done (macOS).'); process.exit(0); } -const outDir = path.join(__dirname, '..', 'dist', 'native'); -fs.mkdirSync(outDir, { recursive: true }); +// ── Windows ──────────────────────────────────────────────────────────────── -const binaries = [ - { - out: 'color-picker', - src: 'src/native/color-picker.swift', - frameworks: ['AppKit'], - }, - { - out: 'snippet-expander', - src: 'src/native/snippet-expander.swift', - frameworks: ['AppKit'], - }, - { - out: 'hotkey-hold-monitor', - src: 'src/native/hotkey-hold-monitor.swift', - frameworks: ['CoreGraphics', 'AppKit', 'Carbon'], - }, - { - out: 'speech-recognizer', - src: 'src/native/speech-recognizer.swift', - frameworks: ['Speech', 'AVFoundation'], - }, - { - out: 'microphone-access', - src: 'src/native/microphone-access.swift', - frameworks: ['AVFoundation'], - }, - { - out: 'input-monitoring-request', - src: 'src/native/input-monitoring-request.swift', - frameworks: ['CoreGraphics'], - }, -]; +if (process.platform === 'win32') { + const binaries = [ + { + out: 'hotkey-hold-monitor.exe', + src: 'src/native/hotkey-hold-monitor.c', + libs: ['user32'], + }, + ]; -for (const { out, src, frameworks } of binaries) { - const outPath = path.join(outDir, out); - const frameworkArgs = frameworks.flatMap((f) => ['-framework', f]); - const cmd = ['swiftc', '-O', '-o', outPath, src, ...frameworkArgs].join(' '); - console.log(`[build-native] Compiling ${out}...`); - execSync(cmd, { stdio: 'inherit' }); + for (const { out, src, libs } of binaries) { + const outPath = path.join(outDir, out); + const libArgs = libs.map((l) => `-l${l}`).join(' '); + const cmd = `gcc -O2 -o "${outPath}" "${src}" ${libArgs}`; + console.log(`[build-native] Compiling ${out}...`); + execSync(cmd, { stdio: 'inherit' }); + } + + console.log('[build-native] Done (Windows).'); + process.exit(0); } -console.log('[build-native] Done.'); +// ── Other platforms ──────────────────────────────────────────────────────── + +console.log(`[build-native] No native binaries for ${process.platform} — skipping.`); diff --git a/src/main/platform/windows.ts b/src/main/platform/windows.ts index bd23a577..b8103f57 100644 --- a/src/main/platform/windows.ts +++ b/src/main/platform/windows.ts @@ -1,11 +1,13 @@ /** * platform/windows.ts * - * Windows stubs for PlatformCapabilities. - * Every method returns a safe value so the app runs without crashing. - * Real Windows implementations will replace these stubs in follow-up PRs. + * Windows implementations of PlatformCapabilities. + * Stubs that still need native work return null/safe values with a comment + * pointing to the follow-up PR that will implement them. */ +import * as path from 'path'; +import type { ChildProcess } from 'child_process'; import type { PlatformCapabilities, MicrophoneAccessStatus, @@ -14,6 +16,56 @@ import type { HotkeyModifiers, } from './interface'; +import { app } from 'electron'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getNativeBinaryPath(name: string): string { + const base = path.join(__dirname, '..', 'native', name); + if (app.isPackaged) { + return base.replace('app.asar', 'app.asar.unpacked'); + } + return base; +} + +// ── Color picker HTML ──────────────────────────────────────────────────────── +// Inlined so the BrowserWindow can load it via a data: URL without needing +// a file on disk (works in both dev and packaged builds). + +const COLOR_PICKER_HTML = ` + + + +
+ + +
+ +`; + +// ── Implementation ──────────────────────────────────────────────────────────── + export const windows: PlatformCapabilities = { readMicrophoneAccessStatus(): MicrophoneAccessStatus { // Windows manages microphone access at the OS level; Electron can call @@ -29,8 +81,8 @@ export const windows: PlatformCapabilities = { }, probeAudioDurationMs(_audioPath: string): number | null { - // afinfo is macOS-only. Will be replaced with a cross-platform probe (ffprobe - // or the Web Audio API duration) in a follow-up PR. + // afinfo is macOS-only. Will be replaced with a cross-platform probe + // (ffprobe or the Web Audio API duration) in a follow-up PR. return null; }, @@ -45,22 +97,96 @@ export const windows: PlatformCapabilities = { }, spawnHotkeyHoldMonitor( - _keyCode: number, - _modifiers: HotkeyModifiers - ) { - // Hold-to-talk requires a low-level keyboard hook. Windows implementation - // (Win32 SetWindowsHookEx / RegisterHotKey) will be added in a follow-up PR. - return null; + keyCode: number, + modifiers: HotkeyModifiers + ): ChildProcess | null { + // Uses hotkey-hold-monitor.exe compiled from src/native/hotkey-hold-monitor.c + // via `npm run build:native`. The binary emits JSON over stdout with the same + // protocol as the macOS Swift binary ({"ready"}, {"pressed"}, {"released"}). + const fs = require('fs'); + const { spawn } = require('child_process'); + + const binaryPath = getNativeBinaryPath('hotkey-hold-monitor.exe'); + if (!fs.existsSync(binaryPath)) { + console.warn( + '[Windows][hold] hotkey-hold-monitor.exe not found.', + 'Run `npm run build:native` to compile it.', + binaryPath + ); + return null; + } + + try { + return spawn( + binaryPath, + [ + String(keyCode), + modifiers.cmd ? '1' : '0', + modifiers.ctrl ? '1' : '0', + modifiers.alt ? '1' : '0', + modifiers.shift ? '1' : '0', + modifiers.fn ? '1' : '0', + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + } catch { + return null; + } }, - spawnSnippetExpander(_keywords: string[]) { - // Snippet expansion requires a system-wide keyboard hook. Windows - // implementation will be added in a follow-up PR. + spawnSnippetExpander(_keywords: string[]): ChildProcess | null { + // Snippet expansion requires a system-wide keyboard hook. + // Windows implementation will be added in a follow-up PR. return null; }, async pickColor(): Promise { - // Will use the Win32 ChooseColor dialog or a JS color picker in a follow-up PR. - return null; + // Opens a small Electron window with a native element. + // nodeIntegration is enabled only for this fully-internal window so we can + // send the result back via ipcRenderer without a preload script. + const { BrowserWindow, ipcMain } = require('electron'); + + return new Promise((resolve) => { + let settled = false; + const settle = (color: string | null) => { + if (settled) return; + settled = true; + resolve(color); + }; + + const win = new BrowserWindow({ + width: 300, + height: 130, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + alwaysOnTop: true, + title: 'Pick a Color', + webPreferences: { + // nodeIntegration is intentionally true here — this window loads + // only the inlined COLOR_PICKER_HTML string and never navigates + // elsewhere. + nodeIntegration: true, + contextIsolation: false, + }, + }); + + const onPicked = (_evt: any, color: string) => { + settle(color || null); + if (!win.isDestroyed()) win.close(); + }; + + ipcMain.once('__sc-color-picked', onPicked); + + win.on('closed', () => { + ipcMain.removeListener('__sc-color-picked', onPicked); + settle(null); + }); + + win.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(COLOR_PICKER_HTML)}` + ); + }); }, }; diff --git a/src/native/hotkey-hold-monitor.c b/src/native/hotkey-hold-monitor.c new file mode 100644 index 00000000..eeac2f4f --- /dev/null +++ b/src/native/hotkey-hold-monitor.c @@ -0,0 +1,182 @@ +/** + * hotkey-hold-monitor.c — Windows global keyboard-hold monitor + * + * Installs a WH_KEYBOARD_LL hook, monitors a specific key + modifier + * combination, and emits newline-delimited JSON to stdout: + * + * {"ready":true} — hook installed successfully + * {"pressed":true} — target key + mods pressed + * {"released":true,"reason":"key-up"} — target key released + * {"released":true,"reason":"modifier-up"} — a required modifier released + * {"error":"..."} — fatal error, exits non-zero + * + * Arguments: + * + * + * cgKeyCode is the macOS CGKeyCode used by parseHoldShortcutConfig in + * main.ts. This file maps it to a Windows Virtual Key code. + * The "cmd" and "fn" arguments are accepted but ignored (Windows has no + * Command or Fn keys at the Win32 API level). + */ + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +/* ── macOS CGKeyCode → Windows Virtual Key code ─────────────────────────── */ + +static int cg_to_vk(int cg) { + switch (cg) { + /* Letters (ANSI layout) */ + case 0: return 'A'; + case 11: return 'B'; + case 8: return 'C'; + case 2: return 'D'; + case 14: return 'E'; + case 3: return 'F'; + case 5: return 'G'; + case 4: return 'H'; + case 34: return 'I'; + case 38: return 'J'; + case 40: return 'K'; + case 37: return 'L'; + case 46: return 'M'; + case 45: return 'N'; + case 31: return 'O'; + case 35: return 'P'; + case 12: return 'Q'; + case 15: return 'R'; + case 1: return 'S'; + case 17: return 'T'; + case 32: return 'U'; + case 9: return 'V'; + case 13: return 'W'; + case 7: return 'X'; + case 16: return 'Y'; + case 6: return 'Z'; + /* Digits */ + case 18: return '1'; + case 19: return '2'; + case 20: return '3'; + case 21: return '4'; + case 23: return '5'; + case 22: return '6'; + case 26: return '7'; + case 28: return '8'; + case 25: return '9'; + case 29: return '0'; + /* Punctuation */ + case 24: return VK_OEM_PLUS; /* = */ + case 27: return VK_OEM_MINUS; /* - */ + case 30: return VK_OEM_6; /* ] */ + case 33: return VK_OEM_4; /* [ */ + case 39: return VK_OEM_7; /* ' */ + case 41: return VK_OEM_1; /* ; */ + case 42: return VK_OEM_5; /* \ */ + case 43: return VK_OEM_COMMA; /* , */ + case 44: return VK_OEM_2; /* / */ + case 47: return VK_OEM_PERIOD; /* . */ + case 50: return VK_OEM_3; /* ` */ + /* Special keys */ + case 36: return VK_RETURN; + case 48: return VK_TAB; + case 49: return VK_SPACE; + case 53: return VK_ESCAPE; + /* Fn (63): not exposed by Win32 — return -1 */ + default: return -1; + } +} + +/* ── Global state ────────────────────────────────────────────────────────── */ + +static HHOOK g_hook = NULL; +static int g_vk = -1; +static int g_need_ctrl = 0; +static int g_need_alt = 0; +static int g_need_shift = 0; +static int g_pressed = 0; + +static void emit(const char *json) { + printf("%s\n", json); + fflush(stdout); +} + +/* ── Low-level keyboard hook ─────────────────────────────────────────────── */ + +static LRESULT CALLBACK kbhook(int nCode, WPARAM wp, LPARAM lp) { + if (nCode == HC_ACTION) { + KBDLLHOOKSTRUCT *kb = (KBDLLHOOKSTRUCT *)lp; + int vk = (int)kb->vkCode; + int ctrl = (GetAsyncKeyState(VK_CONTROL) & 0x8000) ? 1 : 0; + int alt = (GetAsyncKeyState(VK_MENU) & 0x8000) ? 1 : 0; + int shift = (GetAsyncKeyState(VK_SHIFT) & 0x8000) ? 1 : 0; + + if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) { + if (!g_pressed && vk == g_vk && + ctrl == g_need_ctrl && + alt == g_need_alt && + shift == g_need_shift) { + g_pressed = 1; + emit("{\"pressed\":true}"); + } + } else if (wp == WM_KEYUP || wp == WM_SYSKEYUP) { + if (g_pressed) { + if (vk == g_vk) { + emit("{\"released\":true,\"reason\":\"key-up\"}"); + PostQuitMessage(0); + } else { + /* A required modifier key was released */ + int mods_ok = (ctrl == g_need_ctrl) && + (alt == g_need_alt) && + (shift == g_need_shift); + if (!mods_ok) { + emit("{\"released\":true,\"reason\":\"modifier-up\"}"); + PostQuitMessage(0); + } + } + } + } + } + return CallNextHookEx(g_hook, nCode, wp, lp); +} + +/* ── Entry point ─────────────────────────────────────────────────────────── */ + +int main(int argc, char *argv[]) { + if (argc < 7) { + emit("{\"error\":\"Usage: hotkey-hold-monitor cgKeyCode cmd ctrl alt shift fn\"}"); + return 1; + } + + int cg_code = atoi(argv[1]); + /* argv[2] = cmd — no Command key on Windows; ignored */ + g_need_ctrl = strcmp(argv[3], "1") == 0 ? 1 : 0; + g_need_alt = strcmp(argv[4], "1") == 0 ? 1 : 0; + g_need_shift = strcmp(argv[5], "1") == 0 ? 1 : 0; + /* argv[6] = fn — not exposed by Win32; ignored */ + + g_vk = cg_to_vk(cg_code); + if (g_vk < 0) { + emit("{\"error\":\"Key code not supported on Windows\"}"); + return 1; + } + + g_hook = SetWindowsHookExW(WH_KEYBOARD_LL, kbhook, + GetModuleHandleW(NULL), 0); + if (!g_hook) { + emit("{\"error\":\"SetWindowsHookEx failed\"}"); + return 2; + } + + emit("{\"ready\":true}"); + + MSG msg; + while (GetMessage(&msg, NULL, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + UnhookWindowsHookEx(g_hook); + return 0; +} From 43062b5bfc5f9944dfa8b6b1d72f28a8bee99828 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 07:28:45 -0600 Subject: [PATCH 10/49] fix(windows): fix app launch from VS Code and Windows-specific defaults Three issues blocked running SuperCmd on Windows when launched from VS Code (or any Electron-based IDE): 1. ELECTRON_RUN_AS_NODE=1 is inherited from the VS Code parent process, causing Electron to run as plain Node.js instead of an Electron app. This made `require('electron')` resolve to the npm package path string instead of the Electron API, crashing at app.getVersion(). Fix: add scripts/launch-electron.js which explicitly deletes ELECTRON_RUN_AS_NODE from the env before spawning the Electron binary. 2. Alt+Space is reserved by Windows (opens the window system menu) and cannot be registered as a global shortcut. Fix: use Ctrl+Space as the default on Windows, with an automatic migration for existing settings that saved Alt+Space. 3. build:native crashed with a non-zero exit code when gcc/clang/cl were not in PATH, blocking `npm run build`. Fix: probe for available compilers and warn+skip instead of crashing; the app still runs, the hold-hotkey feature is gracefully disabled. --- package.json | 13 ++++++----- scripts/build-native.js | 44 ++++++++++++++++++++++++++++++++++---- scripts/launch-electron.js | 36 +++++++++++++++++++++++++++++++ src/main/settings-store.ts | 27 ++++++++++++++++++----- 4 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 scripts/launch-electron.js diff --git a/package.json b/package.json index 689c8b83..ffb36abc 100644 --- a/package.json +++ b/package.json @@ -10,32 +10,32 @@ "dev": "npm run build:main && concurrently \"npm run watch:main\" \"npm run dev:renderer\" \"npm run start:electron\"", "watch:main": "tsc -p tsconfig.main.json --watch", "dev:renderer": "vite", - "start:electron": "wait-on dist/main/main.js && cross-env NODE_ENV=development electron .", + "start:electron": "wait-on dist/main/main.js && cross-env NODE_ENV=development node scripts/launch-electron.js", "build": "npm run build:main && npm run build:renderer && npm run build:native", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", "build:native": "node scripts/build-native.js", "test": "vitest run", "postinstall": "electron-builder install-app-deps", - "start": "electron .", + "start": "node scripts/launch-electron.js", "package": "npm run build && electron-builder" }, "license": "ISC", "devDependencies": { "@types/node": "^20.11.5", - "vitest": "^4.0.0", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "electron": "^28.1.3", + "electron": "^28.3.3", "electron-builder": "^24.13.3", "postcss": "^8.4.33", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.0.11", + "vitest": "^4.0.0", "wait-on": "^7.2.0" }, "dependencies": { @@ -103,7 +103,10 @@ "target": [ { "target": "nsis", - "arch": ["x64", "arm64"] + "arch": [ + "x64", + "arm64" + ] } ] }, diff --git a/scripts/build-native.js b/scripts/build-native.js index ef231858..b44cf979 100644 --- a/scripts/build-native.js +++ b/scripts/build-native.js @@ -70,6 +70,38 @@ if (process.platform === 'darwin') { // ── Windows ──────────────────────────────────────────────────────────────── if (process.platform === 'win32') { + // Probe for a C compiler. Try gcc (MinGW-w64) first, then clang, then cl + // (MSVC). Any of these can compile the single-file Windows native helpers. + function findCCompiler() { + const { execSync: probe } = require('child_process'); + const candidates = [ + { bin: 'gcc', flagsFor: (out, src, libs) => `-O2 -o "${out}" "${src}" ${libs.map(l => `-l${l}`).join(' ')}` }, + { bin: 'clang', flagsFor: (out, src, libs) => `-O2 -o "${out}" "${src}" ${libs.map(l => `-l${l}`).join(' ')}` }, + { bin: 'cl', flagsFor: (out, src, libs) => `/Fe:"${out}" "${src}" /link ${libs.map(l => `${l}.lib`).join(' ')}` }, + ]; + for (const c of candidates) { + try { + probe(`${c.bin} --version`, { stdio: 'pipe' }); + return c; + } catch { + // not found — try next + } + } + return null; + } + + const compiler = findCCompiler(); + if (!compiler) { + console.warn( + '[build-native] WARNING: No C compiler (gcc/clang/cl) found on PATH.', + 'hotkey-hold-monitor.exe will not be built.', + 'The app will still run; the hold-hotkey feature will be disabled.', + 'To enable it, install MinGW-w64 (e.g. via Scoop: scoop install gcc).' + ); + console.log('[build-native] Done (Windows — native binaries skipped).'); + process.exit(0); + } + const binaries = [ { out: 'hotkey-hold-monitor.exe', @@ -80,10 +112,14 @@ if (process.platform === 'win32') { for (const { out, src, libs } of binaries) { const outPath = path.join(outDir, out); - const libArgs = libs.map((l) => `-l${l}`).join(' '); - const cmd = `gcc -O2 -o "${outPath}" "${src}" ${libArgs}`; - console.log(`[build-native] Compiling ${out}...`); - execSync(cmd, { stdio: 'inherit' }); + const cmd = `${compiler.bin} ${compiler.flagsFor(outPath, src, libs)}`; + console.log(`[build-native] Compiling ${out} with ${compiler.bin}...`); + try { + execSync(cmd, { stdio: 'inherit' }); + } catch (err) { + console.warn(`[build-native] WARNING: Failed to compile ${out}:`, err.message); + console.warn('[build-native] The app will still run; the hold-hotkey feature will be disabled.'); + } } console.log('[build-native] Done (Windows).'); diff --git a/scripts/launch-electron.js b/scripts/launch-electron.js new file mode 100644 index 00000000..79827792 --- /dev/null +++ b/scripts/launch-electron.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * scripts/launch-electron.js + * + * Spawns the Electron binary with ELECTRON_RUN_AS_NODE removed from the + * environment. This is necessary when launching from inside another Electron + * app (e.g. VS Code / Claude Code) which sets ELECTRON_RUN_AS_NODE=1 in the + * inherited environment. That flag causes Electron to run as plain Node.js, + * which breaks require('electron') in the main process. + */ + +const { spawn } = require('child_process'); +const path = require('path'); + +const electronBin = require('electron'); +const args = process.argv.slice(2).length ? process.argv.slice(2) : ['.']; + +const env = { ...process.env }; +delete env.ELECTRON_RUN_AS_NODE; + +const proc = spawn(electronBin, args, { + cwd: path.join(__dirname, '..'), + env, + stdio: 'inherit', + windowsHide: false, +}); + +proc.on('close', (code) => { + process.exit(code ?? 0); +}); + +['SIGINT', 'SIGTERM'].forEach((sig) => { + process.on(sig, () => { + if (!proc.killed) proc.kill(sig); + }); +}); diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 43d3329f..26383c08 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -71,17 +71,25 @@ const DEFAULT_AI_SETTINGS: AISettings = { openaiCompatibleModel: '', }; +// Alt+Space is reserved by Windows (opens the window system menu) and cannot +// be registered as a global shortcut. Use Ctrl+Space as the Windows default. +const DEFAULT_GLOBAL_SHORTCUT = + process.platform === 'win32' ? 'Ctrl+Space' : 'Alt+Space'; + +// macOS uses Command key; Windows uses Ctrl for system-wide shortcuts. +const MOD = process.platform === 'win32' ? 'Ctrl' : 'Command'; + const DEFAULT_SETTINGS: AppSettings = { - globalShortcut: 'Alt+Space', + globalShortcut: DEFAULT_GLOBAL_SHORTCUT, openAtLogin: false, disabledCommands: [], enabledCommands: [], customExtensionFolders: [], commandHotkeys: { - 'system-cursor-prompt': 'Command+Shift+K', - 'system-supercmd-whisper': 'Command+Shift+W', + 'system-cursor-prompt': `${MOD}+Shift+K`, + 'system-supercmd-whisper': `${MOD}+Shift+W`, 'system-supercmd-whisper-speak-toggle': 'Fn', - 'system-supercmd-speak': 'Command+Shift+S', + 'system-supercmd-speak': `${MOD}+Shift+S`, }, pinnedCommands: [], recentCommands: [], @@ -122,8 +130,17 @@ export function loadSettings(): AppSettings { delete parsedHotkeys['system-supercmd-whisper-toggle']; delete parsedHotkeys['system-supercmd-whisper-start']; delete parsedHotkeys['system-supercmd-whisper-stop']; + // On Windows, Alt+Space cannot be registered as a global shortcut + // (Windows reserves it for the system window menu). Migrate any saved + // Alt+Space to the Windows default (Ctrl+Space) automatically. + const savedShortcut: string = parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut; + const migratedShortcut = + process.platform === 'win32' && savedShortcut === 'Alt+Space' + ? DEFAULT_SETTINGS.globalShortcut + : savedShortcut; + settingsCache = { - globalShortcut: parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut, + globalShortcut: migratedShortcut, openAtLogin: parsed.openAtLogin ?? DEFAULT_SETTINGS.openAtLogin, disabledCommands: parsed.disabledCommands ?? DEFAULT_SETTINGS.disabledCommands, enabledCommands: parsed.enabledCommands ?? DEFAULT_SETTINGS.enabledCommands, From 7dbb6c36b69bbd026ba8e22bfce76483a99ca7d2 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 07:58:07 -0600 Subject: [PATCH 11/49] fix(windows): adapt onboarding flow for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 0: show Windows-appropriate 'what gets configured' list - Step 2: hide macOS 'Replace Spotlight' section; show Ctrl instead of Cmd - Step 3: replace 4 macOS permission rows with simplified Windows screen (Windows manages Accessibility/Input Monitoring automatically; only Microphone is noted as needed for Whisper) - Step 4: show Windows-specific hint about Fn key and edge-tts - Step 5: show Ctrl+Shift+S keycap instead of ⌘Cmd+Shift+S - Fix default shortcut fallback to Ctrl+Space on win32 - Add 'platform' and 'homeDir' to ElectronAPI type definition --- src/renderer/src/OnboardingExtension.tsx | 69 +++++++++++++++++++----- src/renderer/types/electron.d.ts | 4 ++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index 4046a061..fcbf4254 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -138,8 +138,9 @@ const OnboardingExtension: React.FC = ({ onComplete, onClose, }) => { + const isWindows = window.electron.platform === 'win32'; const [step, setStep] = useState(0); - const [shortcut, setShortcut] = useState(initialShortcut || 'Alt+Space'); + const [shortcut, setShortcut] = useState(initialShortcut || (isWindows ? 'Ctrl+Space' : 'Alt+Space')); const [shortcutStatus, setShortcutStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [hasValidShortcut, setHasValidShortcut] = useState(!requireWorkingShortcut); const [openedPermissions, setOpenedPermissions] = useState>({}); @@ -341,7 +342,7 @@ const OnboardingExtension: React.FC = ({ }, [step]); const stepTitle = useMemo(() => STEPS[step] || STEPS[0], [step]); - const hotkeyCaps = useMemo(() => toHotkeyCaps(shortcut || 'Alt+Space'), [shortcut]); + const hotkeyCaps = useMemo(() => toHotkeyCaps(shortcut || (isWindows ? 'Ctrl+Space' : 'Alt+Space')), [shortcut, isWindows]); const whisperKeyCaps = useMemo(() => toHotkeyCaps(whisperHoldKey || 'Fn'), [whisperHoldKey]); const handleShortcutChange = async (nextShortcut: string) => { @@ -569,7 +570,7 @@ const OnboardingExtension: React.FC = ({

What gets configured now:

1. Launcher hotkey and inline prompt defaults

-

2. Accessibility, Input Monitoring, Speech Recognition, Microphone

+

2. {isWindows ? 'Microphone access for Whisper' : 'Accessibility, Input Monitoring, Speech Recognition, Microphone'}

3. Whisper dictation and Read mode practice

@@ -633,7 +634,7 @@ const OnboardingExtension: React.FC = ({

Current Launcher Hotkey

- Inline prompt default is now Cmd + Shift + K. Configure launcher key below. + Inline prompt default is now {isWindows ? 'Ctrl' : 'Cmd'} + Shift + K. Configure launcher key below.

@@ -655,7 +656,7 @@ const OnboardingExtension: React.FC = ({

Click the hotkey field above to update your launcher shortcut.

-
+ {!isWindows &&

Replace Spotlight (Cmd + Space)

-
+
}
{requireWorkingShortcut && !hasValidShortcut ? ( @@ -694,6 +695,40 @@ const OnboardingExtension: React.FC = ({ {step === 3 && (
+ {isWindows ? ( +
+
+

Permissions

+

+ Windows manages Accessibility and Input Monitoring automatically — no System Settings changes needed. +

+
+

Microphone access is requested the first time you use Whisper dictation.

+

Click Continue to proceed to the dictation test.

+
+
+
+
+ +
+
+

Microphone

+

+ Windows will prompt for microphone access when you first start Whisper. Grant it to enable voice dictation. +

+
+
+
+ ) : (
= ({ })}
+ )}
)} @@ -840,7 +876,9 @@ const OnboardingExtension: React.FC = ({ className="w-full h-[250px] resize-none rounded-xl border border-cyan-300/55 bg-white/[0.05] px-4 py-3 text-white/90 placeholder:text-white/40 text-base leading-relaxed outline-none shadow-[0_0_0_3px_rgba(34,211,238,0.15)]" />

- Native speech recognition is used by default. For the best experience, use ElevenLabs. + {isWindows + ? 'Edge TTS is used for read-back on Windows. For dictation, set an OpenAI API key in Settings → AI. If the Fn key does not respond, pick a different hold key above.' + : 'Native speech recognition is used by default. For the best experience, use ElevenLabs.'}

@@ -893,11 +931,18 @@ const OnboardingExtension: React.FC = ({

Select the text above then press

- {([ - { symbol: '⌘', label: 'Cmd' }, - { symbol: '⇧', label: 'Shift' }, - { symbol: 'S', label: ''}, - ] as Array<{ symbol: string; label: string | null }>).map((cap, i) => ( + {(isWindows + ? [ + { symbol: 'Ctrl', label: '' as string | null }, + { symbol: '⇧', label: 'Shift' as string | null }, + { symbol: 'S', label: '' as string | null }, + ] + : [ + { symbol: '⌘', label: 'Cmd' as string | null }, + { symbol: '⇧', label: 'Shift' as string | null }, + { symbol: 'S', label: '' as string | null }, + ] + ).map((cap, i) => ( diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 84db6652..c6c6e3aa 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -211,6 +211,10 @@ export interface OllamaLocalModel { } export interface ElectronAPI { + // System Info + homeDir: string; + platform: string; + // Launcher getCommands: () => Promise; executeCommand: (commandId: string) => Promise; From cd8b8377e67e3a8490cdad779a808e0f788eb07f Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 08:09:07 -0600 Subject: [PATCH 12/49] fix(windows): allow Alt+Space override, add PowerToys hint in onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forced migration of Alt+Space → Ctrl+Space in settings-store.ts; fresh installs still default to Ctrl+Space via DEFAULT_GLOBAL_SHORTCUT - Replace macOS Spotlight section in onboarding Step 2 with a PowerToys-aware hint explaining how to use Alt+Space when PowerToys Run is disabled first --- src/main/settings-store.ts | 13 +----- src/renderer/src/OnboardingExtension.tsx | 58 ++++++++++++++---------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 26383c08..ec40025e 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -130,17 +130,8 @@ export function loadSettings(): AppSettings { delete parsedHotkeys['system-supercmd-whisper-toggle']; delete parsedHotkeys['system-supercmd-whisper-start']; delete parsedHotkeys['system-supercmd-whisper-stop']; - // On Windows, Alt+Space cannot be registered as a global shortcut - // (Windows reserves it for the system window menu). Migrate any saved - // Alt+Space to the Windows default (Ctrl+Space) automatically. - const savedShortcut: string = parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut; - const migratedShortcut = - process.platform === 'win32' && savedShortcut === 'Alt+Space' - ? DEFAULT_SETTINGS.globalShortcut - : savedShortcut; - - settingsCache = { - globalShortcut: migratedShortcut, + settingsCache = { + globalShortcut: parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut, openAtLogin: parsed.openAtLogin ?? DEFAULT_SETTINGS.openAtLogin, disabledCommands: parsed.disabledCommands ?? DEFAULT_SETTINGS.disabledCommands, enabledCommands: parsed.enabledCommands ?? DEFAULT_SETTINGS.enabledCommands, diff --git a/src/renderer/src/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index fcbf4254..ec1af800 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -656,32 +656,42 @@ const OnboardingExtension: React.FC = ({

Click the hotkey field above to update your launcher shortcut.

- {!isWindows &&
-
-

Replace Spotlight (Cmd + Space)

- + {isWindows ? ( +
+

Using PowerToys or FancyZones?

+
+

If you have PowerToys Run bound to Alt+Space, you can set Alt+Space here to use SuperCmd instead — disable the PowerToys binding first.

+

Otherwise, Ctrl+Space is recommended as the default on Windows.

+
- {spotlightReplaceStatus === 'success' ? ( -

Spotlight shortcut disabled. SuperCmd is now Cmd + Space.

- ) : spotlightReplaceStatus === 'error' ? ( -

Auto-replace failed. Use the manual steps below.

- ) : null} -
-

Manual: System Settings → Keyboard → Keyboard Shortcuts → Spotlight → disable.

-

Then set the launcher hotkey above to Cmd + Space.

+ ) : ( +
+
+

Replace Spotlight (Cmd + Space)

+ +
+ {spotlightReplaceStatus === 'success' ? ( +

Spotlight shortcut disabled. SuperCmd is now Cmd + Space.

+ ) : spotlightReplaceStatus === 'error' ? ( +

Auto-replace failed. Use the manual steps below.

+ ) : null} +
+

Manual: System Settings → Keyboard → Keyboard Shortcuts → Spotlight → disable.

+

Then set the launcher hotkey above to Cmd + Space.

+
-
} + )}
{requireWorkingShortcut && !hasValidShortcut ? ( From 3c03db9ea35bc35a7b0cb4cc886255d2995a18fe Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 08:22:31 -0600 Subject: [PATCH 13/49] fix(windows): shrink onboarding window and center it on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce ONBOARDING_WINDOW_WIDTH from 1120→900 and ONBOARDING_WINDOW_HEIGHT from 740→600. Position the window vertically centered (workArea height / 2) instead of at the fixed topFactor offset near the top. --- src/main/main.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 79384b9d..652e279f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -89,8 +89,8 @@ function getNativeBinaryPath(name: string): string { const DEFAULT_WINDOW_WIDTH = 860; const DEFAULT_WINDOW_HEIGHT = 540; -const ONBOARDING_WINDOW_WIDTH = 1120; -const ONBOARDING_WINDOW_HEIGHT = 740; +const ONBOARDING_WINDOW_WIDTH = 900; +const ONBOARDING_WINDOW_HEIGHT = 600; const CURSOR_PROMPT_WINDOW_WIDTH = 500; const CURSOR_PROMPT_WINDOW_HEIGHT = 90; const CURSOR_PROMPT_LEFT_OFFSET = 20; @@ -2688,18 +2688,20 @@ function applyLauncherBounds(mode: LauncherMode): void { ? displayY + displayHeight - size.height - 18 : mode === 'speak' ? displayY + 16 - : mode === 'prompt' - ? (() => { - const baseY = caretRect - ? caretRect.y - : focusedInputRect - ? focusedInputRect.y - : (promptAnchorPoint?.y ?? promptFallbackY); - const preferred = baseY - size.height - 10; - if (preferred >= displayY + 8) return preferred; - return clamp(baseY + 16, displayY + 8, displayY + displayHeight - size.height - 8); - })() - : displayY + Math.floor(displayHeight * size.topFactor); + : mode === 'onboarding' + ? displayY + Math.floor((displayHeight - size.height) / 2) + : mode === 'prompt' + ? (() => { + const baseY = caretRect + ? caretRect.y + : focusedInputRect + ? focusedInputRect.y + : (promptAnchorPoint?.y ?? promptFallbackY); + const preferred = baseY - size.height - 10; + if (preferred >= displayY + 8) return preferred; + return clamp(baseY + 16, displayY + 8, displayY + displayHeight - size.height - 8); + })() + : displayY + Math.floor(displayHeight * size.topFactor); mainWindow.setBounds({ x: windowX, y: windowY, From dec3ba6e7e4f1bb0f4e025ad7dfa75457807a0a6 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 08:25:07 -0600 Subject: [PATCH 14/49] fix(windows): restore onboarding width to 1120, reduce height to 680 900px width broke the lg: Tailwind breakpoints causing all two-column layouts to collapse vertically. Keep width at 1120 (above 1024px lg threshold) and reduce height from 740 to 680 for a shorter window that still fits all content. Vertical centering from previous commit retained. --- src/main/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 652e279f..6da62caf 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -89,8 +89,8 @@ function getNativeBinaryPath(name: string): string { const DEFAULT_WINDOW_WIDTH = 860; const DEFAULT_WINDOW_HEIGHT = 540; -const ONBOARDING_WINDOW_WIDTH = 900; -const ONBOARDING_WINDOW_HEIGHT = 600; +const ONBOARDING_WINDOW_WIDTH = 1120; +const ONBOARDING_WINDOW_HEIGHT = 680; const CURSOR_PROMPT_WINDOW_WIDTH = 500; const CURSOR_PROMPT_WINDOW_HEIGHT = 90; const CURSOR_PROMPT_LEFT_OFFSET = 20; From b67e367751a5fd56f44e054e2f4f0e05d910270f Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 08:30:04 -0600 Subject: [PATCH 15/49] fix(windows): improve PowerToys hint readability and explain interception - Raise text opacity from white/55 to white/75 so the hint is readable - Explain that Windows routes Alt+Space to PowerToys/system before SuperCmd can capture it, so users must disable PowerToys Run first - Highlight the action step in white/90 so the fix is immediately clear --- src/renderer/src/OnboardingExtension.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index ec1af800..ee2936ed 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -658,10 +658,11 @@ const OnboardingExtension: React.FC = ({ {isWindows ? (
-

Using PowerToys or FancyZones?

-
-

If you have PowerToys Run bound to Alt+Space, you can set Alt+Space here to use SuperCmd instead — disable the PowerToys binding first.

-

Otherwise, Ctrl+Space is recommended as the default on Windows.

+

Want to use Alt+Space?

+
+

Windows passes Alt+Space to PowerToys Run (or the system window menu) before SuperCmd can see it, so the recorder won't capture it while those are active.

+

To use Alt+Space: open PowerToys → PowerToys Run → disable the Alt+Space shortcut first, then click the hotkey field here and press Alt+Space.

+

Otherwise Ctrl+Space works out of the box and is the recommended default.

) : ( From bffeca1b9e2a371cbb3eb6d17ba80cdacd2dfd3e Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 08:46:51 -0600 Subject: [PATCH 16/49] fix(onboarding): make step 2 card opaque and raise text contrast --- src/renderer/src/OnboardingExtension.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index ee2936ed..cf643fe9 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -621,19 +621,19 @@ const OnboardingExtension: React.FC = ({

Current Launcher Hotkey

-

+

Inline prompt default is now {isWindows ? 'Ctrl' : 'Cmd'} + Shift + K. Configure launcher key below.

@@ -654,21 +654,21 @@ const OnboardingExtension: React.FC = ({ {shortcutStatus === 'error' ? Shortcut unavailable : null}
-

Click the hotkey field above to update your launcher shortcut.

+

Click the hotkey field above to update your launcher shortcut.

{isWindows ? ( -
+

Want to use Alt+Space?

-
+

Windows passes Alt+Space to PowerToys Run (or the system window menu) before SuperCmd can see it, so the recorder won't capture it while those are active.

-

To use Alt+Space: open PowerToys → PowerToys Run → disable the Alt+Space shortcut first, then click the hotkey field here and press Alt+Space.

-

Otherwise Ctrl+Space works out of the box and is the recommended default.

+

To use Alt+Space: open PowerToys → PowerToys Run → disable the Alt+Space shortcut first, then click the hotkey field here and press Alt+Space.

+

Otherwise Ctrl+Space works out of the box and is the recommended default.

) : ( -
+
-

Replace Spotlight (Cmd + Space)

+

Replace Spotlight (Cmd + Space)

+ )} +
+

+ Click to trigger the Windows microphone permission prompt. Grant access to enable Whisper dictation.

+ {permissionNotes['microphone'] ? ( +

{permissionNotes['microphone']}

+ ) : null}
@@ -888,7 +903,7 @@ const OnboardingExtension: React.FC = ({ />

{isWindows - ? 'Edge TTS is used for read-back on Windows. For dictation, set an OpenAI API key in Settings → AI. If the Fn key does not respond, pick a different hold key above.' + ? 'The Fn key is a firmware key on Windows and is not visible to apps — use Ctrl+Shift+Space (the default) or any other combo above. For dictation, set an OpenAI API key in Settings → AI.' : 'Native speech recognition is used by default. For the best experience, use ElevenLabs.'}

From badc9a444b52dd57d523d25826d292bcbf788e0a Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 09:07:30 -0600 Subject: [PATCH 19/49] fix(windows): make Whisper transcription work on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default speechToTextModel was 'native' on all platforms. On Windows there is no native Swift speech recognizer, so the whisper-transcribe IPC handler silently returned an empty string — the UI appeared but nothing was transcribed. - settings-store: default speechToTextModel to '' on Windows so fresh installs fall through to OpenAI (gpt-4o-transcribe) automatically - main: when sttModel === 'native' on Windows, redirect to OpenAI instead of returning '' — gives a proper 'API key not configured' error rather than silent failure for users with saved native setting Users need an OpenAI API key set in Settings -> AI for dictation. --- src/main/main.ts | 11 ++++++++--- src/main/settings-store.ts | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 6da62caf..4d825a86 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6923,9 +6923,14 @@ return appURL's |path|() as text`, provider = 'openai'; model = 'gpt-4o-transcribe'; } else if (sttModel === 'native') { - // Renderer should not call cloud transcription in native mode. - // Return empty transcript instead of surfacing an IPC error. - return ''; + if (process.platform === 'win32') { + // No native Swift speech recognizer on Windows — fall back to OpenAI cloud transcription. + provider = 'openai'; + model = 'gpt-4o-transcribe'; + } else { + // macOS: native mode is handled entirely by the renderer's SFSpeechRecognizer path. + return ''; + } } else if (sttModel.startsWith('openai-')) { provider = 'openai'; model = sttModel.slice('openai-'.length); diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 8c2ab1a8..40e3f54a 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -60,7 +60,8 @@ const DEFAULT_AI_SETTINGS: AISettings = { ollamaBaseUrl: 'http://localhost:11434', defaultModel: '', speechCorrectionModel: '', - speechToTextModel: 'native', + // 'native' uses the macOS Swift speech recognizer; no equivalent on Windows. + speechToTextModel: process.platform === 'win32' ? '' : 'native', speechLanguage: 'en-US', textToSpeechModel: 'edge-tts', edgeTtsVoice: 'en-US-EricNeural', From 5a3f329eb5123066e1e88e3b09185288819dc600 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 09:17:38 -0600 Subject: [PATCH 20/49] fix(windows): implement text selection for Read mode via Ctrl+C clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows osascript is unavailable so getSelectedTextForSpeak always returned empty string, causing startSpeakFromSelection to return false and Read mode to never open. Add a Windows-specific path that simulates Ctrl+C on the foreground window (which still has focus because speak mode runs showWindow:false), polls the clipboard until it changes, captures the selection, then restores the previous clipboard — same pattern as the macOS fallback but using PowerShell SendKeys instead of osascript. --- src/main/main.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/main.ts b/src/main/main.ts index 4d825a86..b9e7a0cb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1486,6 +1486,42 @@ function fetchEdgeTtsVoiceCatalog(timeoutMs = 12000): Promise { const allowClipboardFallback = options?.allowClipboardFallback !== false; const clipboardWaitMs = Math.max(0, Number(options?.clipboardWaitMs ?? 380) || 380); + + // ── Windows path ──────────────────────────────────────────────────────────── + // osascript is macOS-only. On Windows, simulate Ctrl+C on the foreground + // window (which still has focus because speak mode runs with showWindow:false) + // then read the clipboard. The previous clipboard content is restored after. + if (process.platform === 'win32') { + if (!allowClipboardFallback) return ''; + const previousClipboard = systemClipboard.readText(); + try { + const { execFile } = require('child_process'); + const { promisify } = require('util'); + const execFileAsync = promisify(execFile); + await execFileAsync('powershell', [ + '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait("^c")', + ]); + const waitUntil = Date.now() + clipboardWaitMs; + let latest = ''; + while (Date.now() < waitUntil) { + latest = String(systemClipboard.readText() || ''); + if (latest !== String(previousClipboard || '')) break; + await new Promise((resolve) => setTimeout(resolve, 35)); + } + const captured = String(latest || systemClipboard.readText() || '').trim(); + if (!captured || captured === String(previousClipboard || '').trim()) return ''; + return captured; + } catch { + return ''; + } finally { + try { + systemClipboard.writeText(previousClipboard); + } catch {} + } + } + + // ── macOS path ────────────────────────────────────────────────────────────── const fromAccessibility = await (async () => { try { const { execFile } = require('child_process'); From ca07dfa4418665240b15c57bb9b3a62e4581dcee Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 09:41:02 -0600 Subject: [PATCH 21/49] fix(windows): fix Read mode text selection and audio playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text selection: - Replace Ctrl+C (SendKeys) with UIAutomation as primary method — reads selected text directly from the focused element without sending any keystrokes to the user's app, like macOS AXSelectedText - Keep Ctrl+C only as fallback for apps that don't expose TextPattern - Add windowsHide:true to all PowerShell spawns to prevent focus theft Audio playback (ENOENT fix): - /usr/bin/afplay does not exist on Windows; replace with PowerShell -STA + System.Windows.Media.MediaPlayer which handles MP3 natively - Pass audio path via SUPERCMD_AUDIO_PATH env var to avoid escaping - Applied to both the main playAudioFile loop and speak-preview-voice --- src/main/main.ts | 66 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index b9e7a0cb..f472782e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1488,20 +1488,47 @@ async function getSelectedTextForSpeak(options?: { allowClipboardFallback?: bool const clipboardWaitMs = Math.max(0, Number(options?.clipboardWaitMs ?? 380) || 380); // ── Windows path ──────────────────────────────────────────────────────────── - // osascript is macOS-only. On Windows, simulate Ctrl+C on the foreground - // window (which still has focus because speak mode runs with showWindow:false) - // then read the clipboard. The previous clipboard content is restored after. if (process.platform === 'win32') { + const { execFile } = require('child_process'); + const { promisify } = require('util'); + const execFileAsync = promisify(execFile); + + // Primary: UIAutomation — reads the focused element's selected text directly, + // like macOS AXSelectedText, without sending any keystrokes to the user's app. + const fromUIA = await (async () => { + try { + const psScript = [ + 'Add-Type -AssemblyName UIAutomationClient', + 'Add-Type -AssemblyName UIAutomationTypes', + 'try {', + ' $el = [System.Windows.Automation.AutomationElement]::FocusedElement', + ' if ($el) {', + ' $pat = $el.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern)', + ' $sel = $pat.GetSelection()', + ' if ($sel.Length -gt 0) { Write-Output ($sel[0].GetText(-1)) }', + ' }', + '} catch { }', + ].join('; '); + const { stdout } = await execFileAsync('powershell', [ + '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript, + ], { windowsHide: true } as any); + return String(stdout || '').trim(); + } catch { + return ''; + } + })(); + if (fromUIA) return fromUIA; + + // Fallback: clipboard copy — only when UIAutomation returns nothing (app + // doesn't expose the TextPattern). Ctrl+C goes to the foreground window, + // which still has focus because speak mode runs with showWindow:false. if (!allowClipboardFallback) return ''; const previousClipboard = systemClipboard.readText(); try { - const { execFile } = require('child_process'); - const { promisify } = require('util'); - const execFileAsync = promisify(execFile); await execFileAsync('powershell', [ '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait("^c")', - ]); + ], { windowsHide: true } as any); const waitUntil = Date.now() + clipboardWaitMs; let latest = ''; while (Date.now() < waitUntil) { @@ -3863,7 +3890,17 @@ async function startSpeakFromSelection(): Promise { return; } const { spawn } = require('child_process'); - const proc = spawn('/usr/bin/afplay', [prepared.audioPath], { stdio: ['ignore', 'ignore', 'pipe'] }); + // On Windows afplay doesn't exist; use PowerShell STA + MediaPlayer to play MP3. + const proc = process.platform === 'win32' + ? spawn('powershell', [ + '-STA', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', + 'Add-Type -AssemblyName presentationCore; $p=[System.Windows.Media.MediaPlayer]::new(); $p.Open([uri]$env:SUPERCMD_AUDIO_PATH); $p.Play(); $sw=[Diagnostics.Stopwatch]::StartNew(); while(-not $p.NaturalDuration.HasTimeSpan -and $sw.ElapsedMilliseconds -lt 5000){[Threading.Thread]::Sleep(50)}; if($p.NaturalDuration.HasTimeSpan){[Threading.Thread]::Sleep([Math]::Max(0,[int]$p.NaturalDuration.TimeSpan.TotalMilliseconds-80))}; $p.Close()', + ], { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + env: { ...process.env, SUPERCMD_AUDIO_PATH: `file:///${prepared.audioPath.replace(/\\/g, '/')}` }, + } as any) + : spawn('/usr/bin/afplay', [prepared.audioPath], { stdio: ['ignore', 'ignore', 'pipe'] }); session.afplayProc = proc; let stderr = ''; const startedAt = Date.now(); @@ -5163,13 +5200,22 @@ app.whenReady().then(async () => { } const playErr = await new Promise((resolve) => { - const proc = spawn('/usr/bin/afplay', [audioPath], { stdio: ['ignore', 'ignore', 'pipe'] }); + const proc = process.platform === 'win32' + ? spawn('powershell', [ + '-STA', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', + 'Add-Type -AssemblyName presentationCore; $p=[System.Windows.Media.MediaPlayer]::new(); $p.Open([uri]$env:SUPERCMD_AUDIO_PATH); $p.Play(); $sw=[Diagnostics.Stopwatch]::StartNew(); while(-not $p.NaturalDuration.HasTimeSpan -and $sw.ElapsedMilliseconds -lt 5000){[Threading.Thread]::Sleep(50)}; if($p.NaturalDuration.HasTimeSpan){[Threading.Thread]::Sleep([Math]::Max(0,[int]$p.NaturalDuration.TimeSpan.TotalMilliseconds-80))}; $p.Close()', + ], { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + env: { ...process.env, SUPERCMD_AUDIO_PATH: `file:///${audioPath.replace(/\\/g, '/')}` }, + } as any) + : spawn('/usr/bin/afplay', [audioPath], { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; proc.stderr.on('data', (chunk: Buffer | string) => { stderr += String(chunk || ''); }); proc.on('error', (err: Error) => resolve(err)); proc.on('close', (code: number | null) => { if (code && code !== 0) { - resolve(new Error(stderr.trim() || `afplay exited with ${code}`)); + resolve(new Error(stderr.trim() || `audio playback exited with ${code}`)); return; } resolve(null); From 477579e944e086558713b978fb12a711096283ed Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 13:29:28 -0600 Subject: [PATCH 22/49] fix(read-mode): read selection from Electron renderer when SuperCmd window is focused When the onboarding window (or any SuperCmd BrowserWindow) has focus, UIAutomation and Ctrl+C cannot read text selected inside the Chromium renderer. Add a first-priority check that calls executeJavaScript to get window.getSelection().toString() directly from the focused window, falling through to UIAutomation/clipboard only for external apps. --- src/main/main.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/main.ts b/src/main/main.ts index f472782e..19b84b8b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1489,6 +1489,22 @@ async function getSelectedTextForSpeak(options?: { allowClipboardFallback?: bool // ── Windows path ──────────────────────────────────────────────────────────── if (process.platform === 'win32') { + // First priority: if a SuperCmd Electron window (e.g. onboarding) is focused, + // read the selection directly from the renderer via executeJavaScript. + // UIAutomation and Ctrl+C do not work for text inside Chromium renderers. + const allWindows = BrowserWindow.getAllWindows(); + for (const win of allWindows) { + if (!win.isDestroyed() && win.isFocused()) { + try { + const sel = await win.webContents.executeJavaScript('(window.getSelection() || {toString:()=>""}).toString()'); + const selStr = String(sel || '').trim(); + if (selStr) return selStr; + } catch { + // ignore — fall through to UIAutomation + } + } + } + const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); From 5cb89299f6ff332e44e8d6408282af52b205e288 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Wed, 18 Feb 2026 13:44:33 -0600 Subject: [PATCH 23/49] fix(windows): use opaque backgrounds instead of semi-transparent glass blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backdrop-filter compositing on Windows causes a visible fade-in delay and the semi-transparent rgba values (0.74–0.80) look far more see-through than on macOS where vibrancy provides the backing material. - Stamp data-platform attribute on at startup - CSS: [data-platform="win32"] overrides .glass-effect and .cursor-prompt-surface to rgba(12,12,18,0.97) with no backdrop-filter - OnboardingExtension: Windows uses fully opaque gradient wrapper and opaque contentBackground base (no "transparent" bleed-through) - App: actions dropdown and context menu inline styles conditioned on isWindows — remove backdropFilter, lift background to 0.99 opacity --- src/renderer/src/App.tsx | 10 ++++++---- src/renderer/src/OnboardingExtension.tsx | 24 +++++++++++++++++------- src/renderer/src/main.tsx | 5 +++++ src/renderer/styles/index.css | 24 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 04093c82..6627be13 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -49,6 +49,8 @@ import CursorPromptView from './views/CursorPromptView'; const STALE_OVERLAY_RESET_MS = 60_000; +const isWindows = window.electron?.platform === 'win32'; + const App: React.FC = () => { const [commands, setCommands] = useState([]); const [pinnedCommands, setPinnedCommands] = useState([]); @@ -1949,8 +1951,8 @@ const App: React.FC = () => { tabIndex={0} onKeyDown={handleActionsOverlayKeyDown} style={{ - background: 'rgba(30,30,34,0.97)', - backdropFilter: 'blur(40px)', + background: isWindows ? 'rgba(28,28,34,0.99)' : 'rgba(30,30,34,0.97)', + backdropFilter: isWindows ? 'none' : 'blur(40px)', border: '1px solid rgba(255,255,255,0.08)', }} onClick={(e) => e.stopPropagation()} @@ -2004,8 +2006,8 @@ const App: React.FC = () => { style={{ left: Math.min(contextMenu.x, window.innerWidth - 340), top: Math.min(contextMenu.y, window.innerHeight - 320), - background: 'rgba(30,30,34,0.97)', - backdropFilter: 'blur(40px)', + background: isWindows ? 'rgba(28,28,34,0.99)' : 'rgba(30,30,34,0.97)', + backdropFilter: isWindows ? 'none' : 'blur(40px)', border: '1px solid rgba(255,255,255,0.08)', }} onClick={(e) => e.stopPropagation()} diff --git a/src/renderer/src/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index d4f6eec6..3a4eee50 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -493,20 +493,30 @@ const OnboardingExtension: React.FC = ({ const canCompleteOnboarding = hasValidShortcut; const canContinue = step !== 2 || canCompleteOnboarding; const canFinish = canCompleteOnboarding; + + // On Windows, backdrop-filter compositing causes a visible fade-in and the + // semi-transparent rgba values look far more see-through than on macOS. + // Use fully-opaque backgrounds without blur on Windows. + const wrapperStyle: React.CSSProperties = isWindows + ? { background: 'linear-gradient(140deg, rgba(10, 10, 14, 0.99) 0%, rgba(14, 14, 20, 0.99) 52%, rgba(18, 10, 12, 0.99) 100%)' } + : { + background: 'linear-gradient(140deg, rgba(6, 8, 12, 0.80) 0%, rgba(12, 14, 20, 0.78) 52%, rgba(20, 11, 13, 0.76) 100%)', + WebkitBackdropFilter: 'blur(50px) saturate(165%)', + backdropFilter: 'blur(50px) saturate(165%)', + }; + const contentBackground = step === 0 ? 'radial-gradient(circle at 10% 0%, rgba(255, 90, 118, 0.26), transparent 34%), radial-gradient(circle at 92% 2%, rgba(255, 84, 70, 0.19), transparent 36%), linear-gradient(180deg, rgba(5,5,7,0.98) 0%, rgba(8,8,11,0.95) 48%, rgba(10,10,13,0.93) 100%)' - : 'radial-gradient(circle at 5% 0%, rgba(255, 92, 127, 0.30), transparent 36%), radial-gradient(circle at 100% 10%, rgba(255, 87, 73, 0.24), transparent 38%), radial-gradient(circle at 82% 100%, rgba(84, 212, 255, 0.12), transparent 34%), transparent'; + : isWindows + // On Windows, end in an opaque dark base so nothing bleeds through + ? 'radial-gradient(circle at 5% 0%, rgba(255, 92, 127, 0.20), transparent 36%), radial-gradient(circle at 100% 10%, rgba(255, 87, 73, 0.16), transparent 38%), radial-gradient(circle at 82% 100%, rgba(84, 212, 255, 0.08), transparent 34%), linear-gradient(180deg, rgba(10,10,14,0.99) 0%, rgba(12,12,16,0.99) 100%)' + : 'radial-gradient(circle at 5% 0%, rgba(255, 92, 127, 0.30), transparent 36%), radial-gradient(circle at 100% 10%, rgba(255, 87, 73, 0.24), transparent 38%), radial-gradient(circle at 82% 100%, rgba(84, 212, 255, 0.12), transparent 34%), transparent'; return (
)} + {showShortcutGuide && ( + { + setShowShortcutGuide(false); + restoreLauncherFocus(); + }} + /> + )} {contextMenu && contextActions.length > 0 && (
Date: Thu, 19 Feb 2026 04:47:11 -0600 Subject: [PATCH 34/49] fix: show app and settings commands in Extensions tab so disabled ones can be re-enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 'Applications' and 'System Settings' groups to the left panel in Settings → Extensions. Previously only 'system', 'extension', and 'script' category commands were listed, making it impossible to re-enable Windows apps or Settings panels that had been disabled from the launcher. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/settings/ExtensionsTab.tsx | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/renderer/src/settings/ExtensionsTab.tsx b/src/renderer/src/settings/ExtensionsTab.tsx index e31bd5a4..9aa3ed19 100644 --- a/src/renderer/src/settings/ExtensionsTab.tsx +++ b/src/renderer/src/settings/ExtensionsTab.tsx @@ -143,6 +143,14 @@ const ExtensionsTab: React.FC<{ } if (cmd.category === 'system') { map.set(`__supercmd/${cmd.id}`, cmd); + continue; + } + if (cmd.category === 'app') { + map.set(`__apps/${cmd.id}`, cmd); + continue; + } + if (cmd.category === 'settings') { + map.set(`__settings/${cmd.id}`, cmd); } } return map; @@ -245,11 +253,57 @@ const ExtensionsTab: React.FC<{ }); } + const appCommands = commands.filter((cmd) => cmd.category === 'app'); + if (appCommands.length > 0) { + byExt.set('__apps', { + extName: '__apps', + title: 'Applications', + description: 'Installed applications', + owner: 'supercmd', + iconDataUrl: undefined, + preferences: [], + commands: appCommands.map((cmd) => ({ + name: cmd.id, + title: cmd.title, + description: cmd.subtitle || '', + mode: cmd.mode || 'no-view', + interval: cmd.interval, + disabledByDefault: Boolean(cmd.disabledByDefault), + preferences: [], + })), + }); + } + + const settingsCommands = commands.filter((cmd) => cmd.category === 'settings'); + if (settingsCommands.length > 0) { + byExt.set('__settings', { + extName: '__settings', + title: 'System Settings', + description: 'System settings panels', + owner: 'supercmd', + iconDataUrl: undefined, + preferences: [], + commands: settingsCommands.map((cmd) => ({ + name: cmd.id, + title: cmd.title, + description: cmd.subtitle || '', + mode: cmd.mode || 'no-view', + interval: cmd.interval, + disabledByDefault: Boolean(cmd.disabledByDefault), + preferences: [], + })), + }); + } + return Array.from(byExt.values()).sort((a, b) => { if (a.extName === '__supercmd') return -1; if (b.extName === '__supercmd') return 1; if (a.extName === '__script_commands') return -1; if (b.extName === '__script_commands') return 1; + if (a.extName === '__apps') return -1; + if (b.extName === '__apps') return 1; + if (a.extName === '__settings') return -1; + if (b.extName === '__settings') return 1; return a.title.localeCompare(b.title); }); }, [schemas, commands]); From 86960c1367379fc33d584ef37bb813c7fb50e50b Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 04:49:07 -0600 Subject: [PATCH 35/49] =?UTF-8?q?chore:=20mark=20per-command=20hotkeys=20a?= =?UTF-8?q?s=20verified=20on=20Windows=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- FEATURE_MATRIX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 57e5698a..d2513407 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -20,8 +20,8 @@ | Fuzzy search across all commands | — | Scored search across title, keywords, subtitle | ✅ | | Recent commands | — | Most-used commands float to top | ✅ | | Pinned commands | — | Pin any command to keep it at top | ✅ | -| Disable commands | — | Hide any command from results | 🟡 | -| Per-command hotkeys | — | Assign a global hotkey to any command | 🟡 | +| Disable commands | — | Hide any command from results | ✅ | +| Per-command hotkeys | — | Assign a global hotkey to any command | ✅ | | Launcher window show/hide | — | Window hides on blur and on Escape | 🟡 | | Settings window | `system-open-settings` | Full settings UI | 🟡 | | Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | 🟡 | From 151a2e72c425e26b2b662f43eef7e42ca5c65f19 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 04:50:55 -0600 Subject: [PATCH 36/49] =?UTF-8?q?chore:=20mark=20launcher=20window=20show/?= =?UTF-8?q?hide=20as=20verified=20on=20Windows=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- FEATURE_MATRIX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index d2513407..1006dced 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -22,7 +22,7 @@ | Pinned commands | — | Pin any command to keep it at top | ✅ | | Disable commands | — | Hide any command from results | ✅ | | Per-command hotkeys | — | Assign a global hotkey to any command | ✅ | -| Launcher window show/hide | — | Window hides on blur and on Escape | 🟡 | +| Launcher window show/hide | — | Window hides on blur and on Escape | ✅ | | Settings window | `system-open-settings` | Full settings UI | 🟡 | | Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | 🟡 | | Quit | `system-quit-launcher` | Exits the app | 🟡 | From d6d60eaf78b54384cc43e0d645570c7bbd239ef5 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 04:57:48 -0600 Subject: [PATCH 37/49] feat: discover UWP/Store apps (Windows Settings, Calculator, Store, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only Start Menu .lnk → .exe shortcuts were indexed, missing all UWP/packaged apps. Now a second PowerShell pass via Get-StartApps finds every app whose AppID contains '!' (PackageFamilyName!AppId), adds them as 'app' category commands with a shell:AppsFolder\{AUMID} path, and deduplicates against the traditional apps already found. openAppByPath() now routes shell: URIs through shell.openExternal() so UWP apps (Settings, Calculator, Store, Xbox, Mail, etc.) launch correctly. Co-Authored-By: Claude Sonnet 4.6 --- src/main/commands.ts | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/main/commands.ts b/src/main/commands.ts index 048d52fa..fec6d753 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -650,6 +650,50 @@ if($res.Count -eq 0){Write-Output '[]';exit} }); } + // ── UWP / Store apps via Get-StartApps ─────────────────────────────────── + // Get-StartApps returns all Start Menu entries including UWP apps whose + // AppID contains '!' (PackageFamilyName!AppId). Traditional .exe shortcuts + // are already covered above, so we only add entries not yet in results. + const existingNames = new Set(results.map((r) => r.title.toLowerCase())); + try { + const uwpScript = `Get-StartApps | Where-Object { $_.AppID -match '!' } | Select-Object Name,AppID | ConvertTo-Json -Compress`; + const uwpEncoded = Buffer.from(uwpScript, 'utf16le').toString('base64'); + const { stdout: uwpOut } = await execAsync( + `powershell -NoProfile -NonInteractive -EncodedCommand ${uwpEncoded}`, + { timeout: 15_000 } + ); + const rawUwp = uwpOut.trim(); + if (rawUwp && rawUwp !== '[]') { + const parsedUwp = JSON.parse(rawUwp); + const uwpApps: Array<{ Name: string; AppID: string }> = Array.isArray(parsedUwp) + ? parsedUwp + : [parsedUwp]; + for (const item of uwpApps) { + const name = String(item?.Name || '').trim(); + const appId = String(item?.AppID || '').trim(); + if (!name || !appId) continue; + if (WIN_APP_SKIP_RE.test(name)) continue; + if (existingNames.has(name.toLowerCase())) continue; + existingNames.add(name.toLowerCase()); + + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app'; + const idSuffix = crypto.createHash('md5').update(appId).digest('hex').slice(0, 8); + const id = `win-app-${slug}-${idSuffix}`; + + results.push({ + id, + title: name, + keywords: [name.toLowerCase()], + category: 'app' as const, + // shell: URI — opened via shell.openExternal in openAppByPath + path: `shell:AppsFolder\\${appId}`, + }); + } + } + } catch (e) { + console.warn('[Win] UWP app scan failed:', e); + } + return results; } @@ -1005,6 +1049,11 @@ async function discoverSystemSettings(): Promise { async function openAppByPath(appPath: string): Promise { if (process.platform === 'win32') { const { shell } = require('electron'); + // UWP/Store apps are stored as shell:AppsFolder\{AUMID} + if (appPath.startsWith('shell:')) { + await shell.openExternal(appPath); + return; + } const err = await shell.openPath(appPath); if (err) throw new Error(err); return; @@ -1171,6 +1220,62 @@ async function discoverAndBuildCommands(): Promise { keywords: ['snippet', 'export', 'save', 'backup', 'file'], category: 'system', }, + { + id: 'system-color-picker', + title: 'Pick Color', + subtitle: 'Copy hex to clipboard', + keywords: ['color', 'picker', 'hex', 'rgb', 'colour', 'eyedropper', 'powertoys'], + iconEmoji: '🎨', + category: 'system', + }, + { + id: 'system-calculator', + title: 'Calculator', + subtitle: 'Type a math expression to calculate', + keywords: ['calculator', 'math', 'compute', 'calculate', 'arithmetic', 'unit', 'convert'], + iconEmoji: '🧮', + category: 'system', + }, + { + id: 'system-toggle-dark-mode', + title: 'Toggle Dark / Light Mode', + subtitle: 'Switch system appearance', + keywords: ['dark mode', 'light mode', 'theme', 'appearance', 'night', 'powertoys', 'light switch'], + iconEmoji: '🌙', + category: 'system', + }, + { + id: 'system-awake-toggle', + title: 'Awake — Prevent Sleep', + subtitle: 'Keep display awake', + keywords: ['awake', 'sleep', 'prevent sleep', 'caffeinate', 'display', 'powertoys'], + iconEmoji: '☕', + category: 'system', + }, + { + id: 'system-hosts-editor', + title: 'Hosts File Editor', + subtitle: 'Edit hosts file with admin rights', + keywords: ['hosts', 'dns', 'block', 'redirect', 'etc hosts', 'network', 'powertoys'], + iconEmoji: '📝', + category: 'system', + }, + { + id: 'system-env-variables', + title: 'Environment Variables', + subtitle: 'Open system environment variables', + keywords: ['environment', 'variables', 'env', 'path', 'system', 'powertoys'], + iconEmoji: '⚙️', + category: 'system', + }, + { + id: 'system-shortcut-guide', + title: 'Shortcut Guide', + subtitle: 'View keyboard shortcuts', + keywords: ['shortcut', 'hotkey', 'keyboard', 'help', 'guide', 'cheatsheet', 'powertoys'], + iconEmoji: '⌨️', + category: 'system', + }, ]; // Installed community extensions From 9baf5ec4e3fb391635271c6e89e36fadb6b56252 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 04:59:47 -0600 Subject: [PATCH 38/49] =?UTF-8?q?chore:=20mark=20settings=20window,=20onbo?= =?UTF-8?q?arding,=20and=20quit=20as=20verified=20on=20Windows=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- FEATURE_MATRIX.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 1006dced..cf7da3cb 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -23,9 +23,9 @@ | Disable commands | — | Hide any command from results | ✅ | | Per-command hotkeys | — | Assign a global hotkey to any command | ✅ | | Launcher window show/hide | — | Window hides on blur and on Escape | ✅ | -| Settings window | `system-open-settings` | Full settings UI | 🟡 | -| Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | 🟡 | -| Quit | `system-quit-launcher` | Exits the app | 🟡 | +| Settings window | `system-open-settings` | Full settings UI | ✅ | +| Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | ✅ | +| Quit | `system-quit-launcher` | Exits the app | ✅ | | Auto-launch at login | — | Toggle in settings | 🟡 | | App updater | — | Auto-update via electron-updater | 🟡 | From 911e98d10688d9d566832434188bbcc4d23b2e80 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 05:06:37 -0600 Subject: [PATCH 39/49] fix: add Launch at Login toggle to General settings The backend (applyOpenAtLogin, set-open-at-login IPC, setOpenAtLogin bridge) was already fully implemented but the UI toggle was never added to GeneralTab. Added a checkbox card that calls window.electron.setOpenAtLogin() and updates local state immediately. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/settings/GeneralTab.tsx | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/settings/GeneralTab.tsx b/src/renderer/src/settings/GeneralTab.tsx index b5d6665d..ec590839 100644 --- a/src/renderer/src/settings/GeneralTab.tsx +++ b/src/renderer/src/settings/GeneralTab.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect, useMemo } from 'react'; -import { Keyboard, Info, Bug, RefreshCw, Download, RotateCcw } from 'lucide-react'; +import { Keyboard, Info, Bug, RefreshCw, Download, RotateCcw, Power } from 'lucide-react'; import HotkeyRecorder from './HotkeyRecorder'; import type { AppSettings, AppUpdaterStatus } from '../../types/electron'; @@ -217,6 +217,29 @@ const GeneralTab: React.FC = () => {
+
+
+ +

Launch at Login

+
+

+ Automatically start SuperCmd when you log in. +

+ +
+
From ecf3b876ab03bc0a4b3084ead428328ca339e968 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 05:07:41 -0600 Subject: [PATCH 40/49] chore: mark auto-launch and app updater as pending packaged .exe test Co-Authored-By: Claude Sonnet 4.6 --- FEATURE_MATRIX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index cf7da3cb..ccf72755 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -26,8 +26,8 @@ | Settings window | `system-open-settings` | Full settings UI | ✅ | | Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | ✅ | | Quit | `system-quit-launcher` | Exits the app | ✅ | -| Auto-launch at login | — | Toggle in settings | 🟡 | -| App updater | — | Auto-update via electron-updater | 🟡 | +| Auto-launch at login | — | Toggle in settings | 🔵 test on packaged .exe | +| App updater | — | Auto-update via electron-updater | 🔵 test on packaged .exe | ### 1.2 App & Settings Discovery From 66c2b2dd2fcc6813102db2d046b3911628c08d7e Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 05:14:11 -0600 Subject: [PATCH 41/49] fix: correctly launch UWP/Store apps (Calculator, Settings, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. The .lnk scan was picking up Calculator and other UWP apps as C:\Program Files\WindowsApps\...\*.exe paths — these pass Test-Path but fail to open because the WindowsApps folder is access-restricted. Added `-notmatch 'WindowsApps'` to exclude them; Get-StartApps then picks them up with the correct shell:AppsFolder\{AUMID} path. 2. shell.openExternal() is unreliable for shell: URIs on Windows. Replaced with PowerShell Start-Process which handles shell:AppsFolder\ URIs natively. Co-Authored-By: Claude Sonnet 4.6 --- src/main/commands.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/commands.ts b/src/main/commands.ts index fec6d753..63ed8c90 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -602,7 +602,7 @@ foreach($d in $dirs){ try { $sc=$shell.CreateShortcut($_.FullName) $t=$sc.TargetPath - if($t -and $t -match '\\.exe$' -and (Test-Path $t)){ + if($t -and $t -match '\\.exe$' -and $t -notmatch 'WindowsApps' -and (Test-Path $t)){ $res+=[PSCustomObject]@{n=$_.BaseName;p=$t} } } catch {} @@ -1049,9 +1049,14 @@ async function discoverSystemSettings(): Promise { async function openAppByPath(appPath: string): Promise { if (process.platform === 'win32') { const { shell } = require('electron'); - // UWP/Store apps are stored as shell:AppsFolder\{AUMID} + // UWP/Store apps are stored as shell:AppsFolder\{AUMID}. + // shell.openExternal is unreliable for shell: URIs on Windows; + // PowerShell Start-Process handles them correctly. if (appPath.startsWith('shell:')) { - await shell.openExternal(appPath); + const { execFile } = require('child_process'); + const psCmd = `Start-Process "${appPath.replace(/"/g, '`"')}"`; + const encoded = Buffer.from(psCmd, 'utf16le').toString('base64'); + execFile('powershell', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]); return; } const err = await shell.openPath(appPath); From d99a1c9262da4692214c80b71cb154d0957ae55b Mon Sep 17 00:00:00 2001 From: elicep01 Date: Thu, 19 Feb 2026 05:15:45 -0600 Subject: [PATCH 42/49] =?UTF-8?q?chore:=20mark=20Windows=20apps=20(Start?= =?UTF-8?q?=20Menu)=20and=20UWP/Store=20apps=20as=20verified=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- FEATURE_MATRIX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index ccf72755..da36f967 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -33,10 +33,10 @@ | Feature | Description | Status | |---|---|---| -| Windows apps (Start Menu) | Scans Start Menu `.lnk` shortcuts → resolves `.exe` targets | 🟡 | +| Windows apps (Start Menu) | Scans Start Menu `.lnk` shortcuts → resolves `.exe` targets | ✅ | | Windows app icons | Extracted via `System.Drawing.Icon` (PowerShell batch) | 🟡 | | Windows Settings panels | 37 pre-defined `ms-settings:` URIs | 🟡 | -| UWP / Store apps | **NOT discovered** — shortcuts don't point to `.exe` | 🔴 | +| UWP / Store apps | Discovered via `Get-StartApps`, launched via PowerShell `Start-Process` | ✅ | | macOS apps | Spotlight + filesystem scan | ⬜ | | macOS System Settings | `.prefPane` + `.appex` scan | ⬜ | From d2d22f76d07937116f9643bae9da3fa4c0cfee31 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 08:44:47 -0600 Subject: [PATCH 43/49] Windows hardening: text pipelines, snippet expander, script/runtime parity, and feature matrix --- FEATURE_MATRIX.md | 467 ++++++------------------ scripts/build-native.js | 5 + src/main/main.ts | 483 ++++++++++++++++++++++--- src/main/platform/windows.ts | 36 +- src/main/script-command-runner.ts | 108 +++++- src/native/snippet-expander-win.c | 304 ++++++++++++++++ src/renderer/src/raycast-api/index.tsx | 32 +- 7 files changed, 1018 insertions(+), 417 deletions(-) create mode 100644 src/native/snippet-expander-win.c diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index da36f967..0c7c063e 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -1,381 +1,142 @@ -# SuperCmd — Feature Matrix -> Complete audit of SuperCmd vs PowerToys vs Raycast. -> Use this as the master testing checklist. Work through each section, mark status, then build missing features one by one. +# SuperCmd Windows Feature Certification Matrix -**Status legend:** -- ✅ Built & working -- 🟡 Built — untested / needs verification -- 🔴 Missing — needs to be built -- ⬜ N/A — not applicable to Windows or this app's scope +Branch scope: `feat/windows-foundation` ---- +Purpose: +- Enumerate every major SuperCmd capability. +- Define what must work on Windows. +- Capture implementation status, dependencies, validation steps, and release criteria. -## 1. SUPERCMD — Complete Feature Inventory +Status legend: +- `Ready`: implemented in code and has a clear Windows validation path. +- `Needs Validation`: implemented but requires Windows runtime/device verification. +- `Gap`: not yet at parity or requires additional implementation. -### 1.1 Core Launcher +--- -| Feature | Command ID | Description | Status | -|---|---|---|---| -| Global hotkey (open/close) | — | Configurable global shortcut, default `Ctrl+Space` | ✅ | -| Fuzzy search across all commands | — | Scored search across title, keywords, subtitle | ✅ | -| Recent commands | — | Most-used commands float to top | ✅ | -| Pinned commands | — | Pin any command to keep it at top | ✅ | -| Disable commands | — | Hide any command from results | ✅ | -| Per-command hotkeys | — | Assign a global hotkey to any command | ✅ | -| Launcher window show/hide | — | Window hides on blur and on Escape | ✅ | -| Settings window | `system-open-settings` | Full settings UI | ✅ | -| Onboarding wizard | `system-open-onboarding` | Multi-step setup flow | ✅ | -| Quit | `system-quit-launcher` | Exits the app | ✅ | -| Auto-launch at login | — | Toggle in settings | 🔵 test on packaged .exe | -| App updater | — | Auto-update via electron-updater | 🔵 test on packaged .exe | - -### 1.2 App & Settings Discovery - -| Feature | Description | Status | -|---|---|---| -| Windows apps (Start Menu) | Scans Start Menu `.lnk` shortcuts → resolves `.exe` targets | ✅ | -| Windows app icons | Extracted via `System.Drawing.Icon` (PowerShell batch) | 🟡 | -| Windows Settings panels | 37 pre-defined `ms-settings:` URIs | 🟡 | -| UWP / Store apps | Discovered via `Get-StartApps`, launched via PowerShell `Start-Process` | ✅ | -| macOS apps | Spotlight + filesystem scan | ⬜ | -| macOS System Settings | `.prefPane` + `.appex` scan | ⬜ | - -### 1.3 Built-in Utilities - -| Feature | Command ID | Description | Status | -|---|---|---|---| -| **Color Picker** | `system-color-picker` | Native `` window, copies hex to clipboard | 🟡 | -| **Calculator** | `system-calculator` | Inline math + unit conversion as you type | 🟡 | -| **Toggle Dark / Light Mode** | `system-toggle-dark-mode` | Writes Windows registry + sets Electron `nativeTheme` | 🟡 | -| **Awake / Prevent Sleep** | `system-awake-toggle` | Electron `powerSaveBlocker`; subtitle shows Active state | 🟡 | -| **Hosts File Editor** | `system-hosts-editor` | Opens hosts file in elevated Notepad via `Start-Process -Verb RunAs` | 🟡 | -| **Environment Variables** | `system-env-variables` | Opens `sysdm.cpl` Environment Variables dialog via `rundll32` | 🟡 | -| **Shortcut Guide** | `system-shortcut-guide` | In-launcher overlay listing all keyboard shortcuts | 🟡 | -| **File Search** | `system-search-files` | Search files on disk | 🟡 | -| **Clipboard History** | `system-clipboard-manager` | Full clipboard monitor with search, copy, delete | 🟡 | -| **Snippets / Text Expansion** | `system-create-snippet`, `system-search-snippets` | Create, search, pin, import/export text snippets | 🟡 | -| **Script Commands** | `system-create-script-command`, `system-open-script-commands` | Raycast-compatible shell scripts | 🟡 | -| **AI Chat** | Tab key | Streaming AI chat (OpenAI / Anthropic / Ollama) | 🟡 | -| **Cursor / Inline AI Prompt** | `system-cursor-prompt` | Caret-anchored AI prompt, applies result to editor | 🟡 | -| **Whisper Dictation** | `system-supercmd-whisper` | Push-to-talk voice dictation (Fn key hold) | 🟡 | -| **Text-to-Speech (Read)** | `system-supercmd-speak` | Reads selected text aloud (Edge-TTS / ElevenLabs) | 🟡 | -| **Memory** | `system-add-to-memory` | Saves selected text to Supermemory API | 🟡 | -| **Extensions (Raycast-compatible)** | — | Installs & runs community Raycast extensions | 🟡 | -| **Extension Store** | `system-open-extensions-settings` | Browse + install extensions | 🟡 | - -### 1.4 Windows Settings Panels (all 37) - -| Panel | `ms-settings:` URI | Status | -|---|---|---| -| Display | `ms-settings:display` | 🟡 | -| Night Light | `ms-settings:nightlight` | 🟡 | -| Sound | `ms-settings:sound` | 🟡 | -| Bluetooth & Devices | `ms-settings:bluetooth` | 🟡 | -| Network & Internet | `ms-settings:network-status` | 🟡 | -| Wi-Fi | `ms-settings:network-wifi` | 🟡 | -| VPN | `ms-settings:network-vpn` | 🟡 | -| Personalization | `ms-settings:personalization` | 🟡 | -| Background | `ms-settings:personalization-background` | 🟡 | -| Colors & Themes | `ms-settings:colors` | 🟡 | -| Taskbar | `ms-settings:taskbar` | 🟡 | -| Apps & Features | `ms-settings:appsfeatures` | 🟡 | -| Default Apps | `ms-settings:defaultapps` | 🟡 | -| Startup Apps | `ms-settings:startupapps` | 🟡 | -| Accounts | `ms-settings:accounts` | 🟡 | -| Sign-in Options | `ms-settings:signinoptions` | 🟡 | -| Date & Time | `ms-settings:dateandtime` | 🟡 | -| Language & Region | `ms-settings:regionformatting` | 🟡 | -| Notifications | `ms-settings:notifications` | 🟡 | -| Battery & Power | `ms-settings:batterysaver` | 🟡 | -| Storage | `ms-settings:storagesense` | 🟡 | -| Multitasking | `ms-settings:multitasking` | 🟡 | -| Privacy & Security | `ms-settings:privacy` | 🟡 | -| Microphone Privacy | `ms-settings:privacy-microphone` | 🟡 | -| Camera Privacy | `ms-settings:privacy-webcam` | 🟡 | -| Location | `ms-settings:privacy-location` | 🟡 | -| Windows Update | `ms-settings:windowsupdate` | 🟡 | -| Troubleshoot | `ms-settings:troubleshoot` | 🟡 | -| Recovery | `ms-settings:recovery` | 🟡 | -| Activation | `ms-settings:activation` | 🟡 | -| Developer Mode | `ms-settings:developers` | 🟡 | -| Mouse | `ms-settings:mousetouchpad` | 🟡 | -| Keyboard | `ms-settings:keyboard` | 🟡 | -| Printers & Scanners | `ms-settings:printers` | 🟡 | -| Gaming | `ms-settings:gaming-gamebar` | 🟡 | -| Optional Features | `ms-settings:optionalfeatures` | 🟡 | -| About This PC | `ms-settings:about` | 🟡 | +## 1) Launcher and Core Command System + +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| Global launcher hotkey | Toggle launcher reliably from any foreground app | `src/main/main.ts`, `src/main/settings-store.ts` | Needs Validation | Verify open/close from browser, terminal, Office, IDE; verify no stuck focus. | +| Fuzzy command search | Rank by title/keywords/alias/recent/pinned | `src/renderer/src/App.tsx` | Needs Validation | Search with exact, partial, alias, typo-like queries; compare result ordering consistency. | +| Recent commands | Most recently executed commands prioritized | `src/main/settings-store.ts`, `src/renderer/src/App.tsx` | Needs Validation | Execute varied commands; restart app; confirm order persists. | +| Pinned commands | Pinned commands remain promoted | `src/main/settings-store.ts`, `src/renderer/src/App.tsx` | Needs Validation | Pin, reorder, restart, unpin; verify deterministic ordering. | +| Disable commands | Disabled commands hidden and non-runnable | `src/main/main.ts`, `src/renderer/src/settings/ExtensionsTab.tsx` | Needs Validation | Disable app/system/extension commands and verify omission from search and hotkey execution. | +| Per-command hotkeys | Commands launch from global shortcuts | `src/main/main.ts` | Needs Validation | Configure shortcuts for each command category; test conflicts and duplicate prevention. | +| Command aliases | Alias becomes searchable keyword | `src/main/commands.ts`, `src/renderer/src/settings/ExtensionsTab.tsx` | Needs Validation | Add/edit/remove aliases and verify search index updates immediately and after restart. | --- -## 2. POWERTOYS — Full Feature List vs SuperCmd +## 2) Discovery and Indexing (Windows Native) -> PowerToys is a suite of standalone Windows utilities. PowerToys Run is its launcher component. +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| Win32 app discovery | Start Menu `.lnk` apps discoverable and launchable | `discoverWindowsApplications()` in `src/main/commands.ts` | Needs Validation | Verify common apps (Notepad, VS Code, Chrome, system tools). | +| UWP app discovery | Store apps discoverable and launchable | `Get-StartApps` flow in `src/main/commands.ts` | Needs Validation | Validate Calculator/Settings/Xbox/Photos launch flows. | +| Windows settings panels | All `ms-settings:` commands route correctly | `WINDOWS_SETTINGS_PANELS` in `src/main/commands.ts` | Needs Validation | Execute all 37 panel commands and record pass/fail per URI. | +| App/settings icon extraction | Icons render with fallback behavior on failure | `extractWindowsIcons()` in `src/main/commands.ts` | Needs Validation | Confirm icon rendering for mixed Win32/UWP targets and corrupted shortcuts. | -### 2.1 PowerToys Run (the Launcher) +--- -| PT Run Plugin / Feature | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| Application launcher | Launch installed apps | App discovery (Start Menu) | 🟡 | -| File search | Search files by name | `system-search-files` | 🟡 | -| Calculator | Evaluate math expressions | `system-calculator` (inline) | 🟡 | -| Unit converter | Convert units (km→mi, °C→°F, etc.) | `system-calculator` (smart-calculator.ts) | 🟡 | -| Currency converter | Convert currencies | 🔴 | 🔴 | -| Windows Settings search | Open specific settings panels | 37 `win-settings-*` commands | 🟡 | -| Shell / Terminal command | Run `>cmd` to execute shell commands | 🔴 Script Commands exist but no `>` prefix | 🔴 | -| Web search | `?query` prefix to web search | 🔴 | 🔴 | -| Window Walker | Switch to any open window | 🔴 | 🔴 | -| Process kill | Kill a running process by name | 🔴 | 🔴 | -| Registry search | Browse Windows registry | 🔴 | 🔴 | -| VS Code workspaces | Open recent VS Code workspaces | 🔴 | 🔴 | -| OneNote search | Search OneNote pages | 🔴 | 🔴 | -| GUID / hash generator | Generate random GUIDs, hashes | 🔴 | 🔴 | -| Indexer / Everything | Fast file search via Windows Search | 🔴 (uses own file search) | 🔴 | -| Clipboard history | Access recent clipboard items | `system-clipboard-manager` | 🟡 | -| URI handler (`raycast://`) | Deep link protocol | ✅ `raycast://` deep links | ✅ | -| Result copy-to-clipboard | Copy any result without executing | 🔴 | 🔴 | - -### 2.2 PowerToys Standalone Utilities - -| PT Utility | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| **Always on Top** | Pin any window to stay above all others (Win+Ctrl+T) | 🔴 | 🔴 | -| **Awake** | Prevent system sleep (tray icon with timer options) | `system-awake-toggle` (no timer) | 🟡 (no timer) | -| **Color Picker** | Screen eyedropper — click any pixel to copy its color | `system-color-picker` (picker dialog, not screen eyedropper) | 🟡 (dialog only, not pixel picker) | -| **Crop & Lock** | Crop or lock a region of another window into a mini window | 🔴 | 🔴 | -| **Environment Variables** | GUI editor for system/user env vars (add/edit/delete) | `system-env-variables` (opens sysdm.cpl) | 🟡 (no built-in editor) | -| **FancyZones** | Snap windows into custom grid layouts | 🔴 | 🔴 | -| **File Explorer Add-ons** | Preview panels for SVG, Markdown, PDF, GCODE, etc. | 🔴 | 🔴 | -| **File Locksmith** | Right-click → "What's locking this file?" | 🔴 | 🔴 | -| **Hosts File Editor** | GUI table editor for `/etc/hosts` with add/disable/delete | `system-hosts-editor` (opens Notepad) | 🟡 (no GUI editor) | -| **Image Resizer** | Right-click images → resize to presets | 🔴 | 🔴 | -| **Keyboard Manager** | Remap any key to another key or shortcut, system-wide | 🔴 | 🔴 | -| **Mouse Highlighter** | Visual ring around mouse cursor (Win+Shift+H) | 🔴 | 🔴 | -| **Mouse Jump** | Teleport mouse across multiple monitors | 🔴 | 🔴 | -| **Mouse Pointer Crosshairs** | Draw crosshair lines centered on mouse | 🔴 | 🔴 | -| **Mouse Without Borders** | Control multiple PCs with one mouse/keyboard | 🔴 | 🔴 | -| **Paste as Plain Text** | Strip formatting on paste (Win+Ctrl+Alt+V) | 🔴 | 🔴 | -| **Peek** | Quick Look–style file previewer (Space to preview) | 🔴 | 🔴 | -| **PowerRename** | Bulk rename files with regex, search-replace, case | 🔴 | 🔴 | -| **Quick Accent** | Hold a key to show accent variants (é ê ë…) | 🔴 | 🔴 | -| **Registry Preview** | Visualize and diff `.reg` files | 🔴 | 🔴 | -| **Screen Ruler** | Measure pixel distances/areas on screen | 🔴 | 🔴 | -| **Shortcut Guide** | Hold Win to show all Win+key shortcuts overlay | `system-shortcut-guide` (SuperCmd shortcuts, not Win keys) | 🟡 (SuperCmd only) | -| **Text Extractor** | Screen OCR — drag to select region, copy text | 🔴 | 🔴 | -| **Video Conference Mute** | Global mic/camera mute toggle across any app | 🔴 | 🔴 | -| **Workspaces** | Save and restore window layouts (which apps, where) | 🔴 | 🔴 | +## 3) Native SuperCmd System Commands + +Source command list defined in `src/main/commands.ts`. + +| Command ID | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| `system-open-settings` | Open settings window | `src/main/main.ts` | Ready | Validate tab state and window lifecycle. | +| `system-open-ai-settings` | Open AI tab directly | `src/main/main.ts` | Ready | Verify direct navigation and persistence writes. | +| `system-open-extensions-settings` | Open extensions tab/store flow | `src/main/main.ts` | Ready | Validate no broken routing from launcher/hotkey. | +| `system-open-onboarding` | Open onboarding mode reliably | `src/main/main.ts`, `src/renderer/src/App.tsx` | Needs Validation | Validate first-run and re-open onboarding sequences. | +| `system-quit-launcher` | Exit cleanly | `src/main/main.ts` | Ready | Verify no orphan process remains. | +| `system-calculator` | Inline math/conversion | `src/renderer/src/smart-calculator.ts` | Needs Validation | Validate arithmetic, units, and copy flow. | +| `system-color-picker` | Return picked color to clipboard | `src/main/platform/windows.ts`, `src/main/main.ts` | Needs Validation | Verify picker cancel/confirm paths and clipboard value format. | +| `system-toggle-dark-mode` | Toggle app/system mode behavior | `src/main/main.ts` | Needs Validation | Validate repeated toggles on Win10/Win11. | +| `system-awake-toggle` | Prevent sleep toggle behavior | `src/main/main.ts` | Needs Validation | Validate active state, toggle off/on, and subtitle updates. | +| `system-hosts-editor` | Open editable hosts flow with elevation | `src/main/main.ts` | Needs Validation | Validate normal user + UAC elevation flow. | +| `system-env-variables` | Open environment variables settings path | `src/main/main.ts` | Needs Validation | Validate across Win10/Win11. | +| `system-shortcut-guide` | Open shortcut guide view | `src/renderer/src/App.tsx` | Ready | Validate view opens/closes and shortcuts display correctly. | --- -## 3. RAYCAST (macOS) — Full Feature List vs SuperCmd +## 4) Clipboard, Snippets, and Text Insertion Paths -> Raycast is the primary inspiration for SuperCmd's architecture. +These are critical because many features depend on shared text insertion behavior. -### 3.1 Core Launcher Features +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| Clipboard history CRUD | Store/search/copy/delete/paste entries | `src/main/main.ts`, `src/main/preload.ts`, clipboard manager modules | Needs Validation | Validate text/html/file entries and persistence across restart. | +| Hide-and-paste pipeline | Paste to previously active app after launcher hides | `hideAndPaste()` in `src/main/main.ts` | Needs Validation | Validate in Notepad, VS Code, browser inputs, Office fields. | +| Direct text typing | Type generated text into focused app | `typeTextDirectly()` in `src/main/main.ts` | Needs Validation | Validate punctuation, braces, multiline behavior. | +| Replace live text | Backspace + replace workflows for whisper/prompt | `replaceTextDirectly()`, `replaceTextViaBackspaceAndPaste()` | Needs Validation | Validate for short/long selections and multiline replacements. | +| Snippet manager CRUD | Create/edit/delete/pin/import/export | `src/main/snippet-store.ts`, `src/renderer/src/SnippetManager.tsx` | Needs Validation | Validate all actions plus restart persistence. | +| Snippet paste action | Insert snippet into active app | `snippet-paste` IPC + shared paste pipeline in `src/main/main.ts` | Needs Validation | Validate plain and dynamic snippet variants. | +| Native snippet keyword expansion | Background keyword detection and in-place expansion | `src/native/snippet-expander-win.c`, `src/main/platform/windows.ts`, `expandSnippetKeywordInPlace()` in `src/main/main.ts` | Needs Validation | Validate delimiter handling, backspace replacement correctness, and non-interference while modifiers are pressed. | -| Raycast Feature | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| App launcher | Launch apps with fuzzy search | App discovery | 🟡 | -| Recent commands | Recently used commands | ✅ | ✅ | -| Aliases | Set short aliases for any command | 🔴 | 🔴 | -| Fallback commands | Run a search in browser/app if no result found | 🔴 | 🔴 | -| Quicklinks | Saved URLs/bookmarks, optionally with `{query}` placeholder | 🔴 | 🔴 | -| Navigation history | Back/forward through views | 🔴 | 🔴 | -| Action Panel (⌘K) | Context menu of actions for selected item | 🟡 (partial) | 🟡 | -| Raycast deep links | `raycast://` URI scheme | ✅ | ✅ | - -### 3.2 Built-in Utilities - -| Raycast Utility | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| **Calculator** | Inline math (TypeScript-based, shows result under query) | `system-calculator` | 🟡 | -| **Unit Converter** | Convert km↔mi, °C↔°F, l↔gal, etc. inline | `system-calculator` (smart-calculator.ts) | 🟡 | -| **Currency Converter** | Live exchange rates | 🔴 | 🔴 | -| **Color Picker** | Screen eyedropper → copies hex/rgb/hsl | `system-color-picker` (dialog only) | 🟡 (not screen eyedropper) | -| **Clipboard History** | Full clipboard history with search | `system-clipboard-manager` | 🟡 | -| **Snippets / Text Expansion** | Create shortcuts that expand to text | `system-create-snippet` | 🟡 | -| **File Search** | Search files on disk | `system-search-files` | 🟡 | -| **System Commands** | Sleep, restart, shut down, lock screen, empty trash | 🔴 most | 🔴 | -| **Window Management** | Resize/position windows (halves, quarters, maximize) | 🔴 | 🔴 | -| **Focus Mode / Do Not Disturb** | Pause notifications for a set time | 🔴 | 🔴 | -| **Floating Notes** | Always-on-top scratchpad (Cmd+Shift+N) | 🔴 | 🔴 | -| **Confetti** | Celebration animation (just for fun) | 🔴 | 🔴 | -| **Emoji Search** | Search and insert emoji | 🔴 | 🔴 | -| **Screen OCR** | Capture a region and extract text | 🔴 | 🔴 | -| **Dictionary** | Look up word definitions | 🔴 | 🔴 | -| **Translation** | Translate text using DeepL/Google | 🔴 | 🔴 | - -### 3.3 AI Features (Raycast AI) - -| Raycast AI Feature | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| AI Chat | Chat with AI models | Tab → AI Chat | ✅ | -| AI Commands | Pre-built prompts (summarize, improve writing, etc.) | 🔴 | 🔴 | -| AI Inline Cursor | Apply AI to selected text in any app | `system-cursor-prompt` | 🟡 | -| AI Extensions | Extensions can call AI with `useAI` | ✅ (`use-ai.ts` shim) | ✅ | -| Multiple AI providers | OpenAI, Anthropic, etc. | ✅ | ✅ | -| Raycast AI (cloud) | Raycast's own managed AI | 🔴 | 🔴 | +--- -### 3.4 Extensions System +## 5) AI, Memory, Whisper, and Speak -| Feature | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| Extensions marketplace | Browse/install community extensions | ✅ Extension Store | ✅ | -| Extension preferences | Per-extension settings UI | ✅ | ✅ | -| Extension deep links | `raycast://extensions/...` | ✅ | ✅ | -| List view | Extensions render a searchable list | ✅ | ✅ | -| Detail view | Extensions render rich markdown detail | ✅ | ✅ | -| Form view | Extensions collect user input | ✅ | ✅ | -| Grid view | Extensions render an icon grid | ✅ | ✅ | -| Menu bar extras | Extensions render in the macOS menu bar | ✅ (Windows tray stub) | 🟡 | -| Script commands | Shell scripts with metadata headers | ✅ | ✅ | -| No-view commands | Run and hide immediately | ✅ | ✅ | -| `@raycast/api` shim | Full API surface for extension compat | ✅ | ✅ | -| OAuth for extensions | PKCE OAuth flow | ✅ | ✅ | -| `useFetch` / `useCachedPromise` hooks | Async data hooks | ✅ | ✅ | -| `useAI` hook | AI integration in extensions | ✅ | ✅ | -| `useSQL` hook | Query SQLite databases | ✅ | ✅ | -| `BrowserExtension` API | Read browser tabs/content | 🟡 stub | 🟡 | - -### 3.5 Productivity / Workflow - -| Feature | What It Does | SuperCmd Equivalent | SuperCmd Status | -|---|---|---|---| -| Whisper / Dictation | Voice input (Raycast Pro) | `system-supercmd-whisper` | 🟡 | -| Text-to-Speech / Read | Read text aloud (Raycast Pro) | `system-supercmd-speak` | 🟡 | -| Memory / Notes | Save context across sessions | `system-add-to-memory` | 🟡 | -| Raycast for Teams | Shared snippets, quicklinks across org | 🔴 | 🔴 | -| Calendar events | Show today's calendar events | 🔴 | 🔴 | -| Contacts | Search and call/message contacts | 🔴 | 🔴 | -| Browser history search | Search browser history | 🔴 | 🔴 | -| Browser bookmarks | Search browser bookmarks | 🔴 | 🔴 | +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| AI chat stream | Prompt/stream/cancel complete without UI lockups | `src/main/main.ts`, `src/renderer/src/views/AiChatView.tsx` | Needs Validation | Validate provider switching and long-stream interruption. | +| Inline AI prompt | Apply generated text to active app | `system-cursor-prompt`, `prompt-apply-generated-text` in `src/main/main.ts` | Needs Validation | Validate from multiple host apps/editors. | +| Memory add | Add selected text to memory service | `system-add-to-memory` flow in `src/main/main.ts` | Needs Validation | Validate empty-selection errors and success messages. | +| Whisper overlay lifecycle | Start/listen/stop/release reliably | whisper flows in `src/main/main.ts`, `src/renderer/src/SuperCmdWhisper.tsx` | Needs Validation | Validate hotkey open/close race conditions. | +| Hold monitor | Detect hold/release for whisper controls | `hotkey-hold-monitor.exe` via `src/main/platform/windows.ts` | Needs Validation | Validate multiple shortcuts and release reasons. | +| Speak selected text | Read flow start/stop/status sync | `system-supercmd-speak` and speak IPC in `src/main/main.ts` | Needs Validation | Validate stop behavior, focus restoration, and overlay state. | +| Local speech backend | Use supported backend on Windows | `resolveSpeakBackend()` in `src/main/platform/windows.ts` | Needs Validation | Validate `edge-tts` presence/absence behavior. | +| Audio duration probe | Needed for parity metrics | `probeAudioDurationMs()` in `src/main/platform/windows.ts` | Gap | Currently returns `null`; implementable in a follow-up if required. | --- -## 4. MISSING FEATURES — Priority Build List +## 6) Extensions and Raycast Compatibility -### Tier 1: Easy wins (small scope, high value) +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| Extension discovery/indexing | Installed extension commands visible and executable | `src/main/extension-runner.ts` | Needs Validation | Validate command list refresh after install/uninstall. | +| Extension store install/uninstall | End-to-end installation flow | `src/main/main.ts`, `src/renderer/src/settings/StoreTab.tsx` | Needs Validation | Validate fresh install, update, uninstall, reinstall paths. | +| Runtime bundle execution | Extension commands run in renderer runtime | `src/renderer/src/ExtensionView.tsx` | Needs Validation | Validate list/detail/form/grid commands. | +| Raycast API shim | Core APIs behave compatibly | `src/renderer/src/raycast-api/index.tsx` | Needs Validation | Validate representative extensions that use hooks/actions/forms. | +| OAuth callbacks/tokens | Auth flow and token persistence work | OAuth modules in main + renderer | Needs Validation | Validate sign-in, callback, token reuse, logout. | +| Menu bar/tray extras | Extension-driven tray menus work | `menubar-*` IPC in `src/main/main.ts` | Needs Validation | Validate menu updates, click routing, cleanup. | +| Script commands | Parse/execute Raycast-style script metadata | `src/main/script-command-runner.ts` | Needs Validation | Validate inline/fullOutput/no-view modes and arguments. | -| Feature | Effort | Reference | Notes | -|---|---|---|---| -| **Shell command runner** (`>` prefix) | Small | PT Run, Raycast | Type `>ipconfig` to run a shell command inline | -| **Web search** (`?` or custom prefix) | Small | PT Run, Raycast | Type `? cats` → opens browser with search | -| **Window Walker** (switch windows) | Medium | PT Run | Enumerate open windows, click to focus | -| **Process kill** | Small | PT Run | List running processes, kill selected | -| **UWP / Store app discovery** | Small | PT | `Get-StartApps` PowerShell to enumerate pinned/UWP apps | -| **System power commands** | Trivial | Raycast | Sleep, restart, shut down, lock screen, hibernate | -| **Emoji picker** | Small | Raycast | Search emoji by name, click to copy | -| **Aliases** | Small | Raycast | Short keyword that maps to any command | -| **Quicklinks** | Small | Raycast | Saved URL with optional `{query}` placeholder | -| **Fallback commands** | Small | Raycast | "Search Google for this" when no results | -| **Screen color eyedropper** | Medium | PT, Raycast | Click any pixel on screen (not just a dialog) | -| **Awake with timer** | Small | PT Awake | Set duration before sleep re-enables | - -### Tier 2: Medium effort (meaningful features) - -| Feature | Effort | Reference | Notes | -|---|---|---|---| -| **Window management** | Medium | Raycast | Snap to halves/quarters/maximize via keyboard | -| **AI prompt library** | Medium | Raycast AI | Curated prompts: summarize, improve writing, translate, explain | -| **Hosts File GUI editor** | Medium | PT | Table editor inside the launcher (add/disable/delete entries) | -| **Image resizer** | Medium | PT | Drop image → select preset → resize | -| **Bulk rename (PowerRename)** | Medium | PT | Regex rename of multiple files | -| **Floating notes** | Medium | Raycast | Always-on-top scratchpad window | -| **Screen OCR / Text Extractor** | Medium | PT, Raycast | Select region → copy text | -| **Focus mode / DND** | Small | Raycast | Pause Windows notifications for N minutes | -| **Dictionary / word lookup** | Small | Raycast | Define word inline | -| **Translation** | Small | Raycast | Translate text via DeepL/LibreTranslate | -| **Currency converter** | Small | Raycast, PT Run | Live rates from an API | -| **GUID / hash generator** | Small | PT Run | `guid`, `md5 sometext`, `sha256 sometext` | -| **Browser history/bookmarks** | Medium | Raycast | Search Chrome/Edge/Firefox history | -| **Always on Top** | Small | PT | Toggle always-on-top for frontmost window | -| **Paste as Plain Text** | Small | PT | Strip formatting on paste (global shortcut) | - -### Tier 3: Large / complex - -| Feature | Effort | Reference | Notes | +--- + +## 7) Settings, Persistence, and Packaging + +| Capability | Windows requirement | Implementation path | Status | Validation detail | +|---|---|---|---|---| +| Settings persistence | All toggles and hotkeys persist on restart | `src/main/settings-store.ts` | Needs Validation | Validate AI, aliases, hotkeys, pinned, disabled commands. | +| Open at login | Startup registration works in packaged app | `src/main/main.ts` | Needs Validation | Validate installer build on real Windows session. | +| Updater flow | Update state, download, install lifecycle works | updater IPC in `src/main/main.ts` | Needs Validation | Validate from packaged release channel only. | +| OAuth token persistence | Separate token store integrity | `src/main/settings-store.ts` + tests | Ready | Existing unit tests pass; still validate Windows file permissions path. | + +--- + +## 8) Windows Build Requirements + +| Item | Requirement | Path | Status | |---|---|---|---| -| **FancyZones / Window layouts** | Large | PT | Custom zone grid layout manager | -| **Keyboard Manager** | Large | PT | System-wide key remapping | -| **Video Conference Mute** | Medium | PT | Global mic/camera toggle overlay | -| **Workspaces** | Large | PT | Save/restore app window layouts | -| **Screen Ruler** | Medium | PT | Pixel measurement tool | -| **Calendar integration** | Medium | Raycast | Show today's events from Google/Outlook | -| **Contacts** | Medium | Raycast | Search system/Google contacts | -| **Raycast for Teams (multi-user sync)** | Large | Raycast | Shared snippets/quicklinks per org | +| Hotkey hold monitor binary | `hotkey-hold-monitor.exe` compiled on Windows | `scripts/build-native.js`, `src/native/hotkey-hold-monitor.c` | Ready | +| Snippet expander binary | `snippet-expander-win.exe` compiled on Windows | `scripts/build-native.js`, `src/native/snippet-expander-win.c` | Ready | +| Speech recognizer binary | `speech-recognizer.exe` compiled with `csc.exe` | `scripts/build-native.js`, `src/native/speech-recognizer.cs` | Ready | +| Native binary packaging | binaries shipped in `dist/native` and unpacked | `package.json` (`asarUnpack`) | Needs Validation | --- -## 5. TESTING CHECKLIST - -Use this section when running through each feature manually. - -### How to run the dev build -``` -npm run dev -``` -(inside `C:\Users\elice\OneDrive\Desktop\SuperCmd\SuperCmd`) - -### Core launcher -- [ ] Open with `Ctrl+Space` -- [ ] Close with `Escape` or `Ctrl+Space` -- [ ] Type to search — results appear instantly -- [ ] Arrow keys navigate up/down -- [ ] Enter executes selected command -- [ ] Tab opens AI chat -- [ ] `Cmd+K` opens action panel for selected command -- [ ] Pin command with `Cmd+Shift+P` -- [ ] Disable command with `Cmd+Shift+D` - -### Built-in utilities (new — all need testing) -- [ ] **Pick Color** — search "color", press Enter → color dialog opens → pick → hex in clipboard -- [ ] **Calculator** — search "calculator", press Enter → search clears → type `5 * 8` → shows `40` below -- [ ] **Calculator inline** — type `10 km in miles` directly → shows result card -- [ ] **Toggle Dark/Light Mode** — search "dark", press Enter → system theme flips -- [ ] **Awake** — search "awake", press Enter → subtitle shows "Active" → run again → subtitle returns to "Keep display awake" -- [ ] **Hosts File Editor** — search "hosts", press Enter → UAC prompt → Notepad opens with hosts file -- [ ] **Environment Variables** — search "env", press Enter → Environment Variables dialog opens -- [ ] **Shortcut Guide** — search "shortcut", press Enter → overlay appears with keyboard shortcuts → Escape closes - -### Windows Settings panels -- [ ] Search "display" → "Display" result appears → Enter → Windows Display settings opens -- [ ] Search "bluetooth" → Opens Bluetooth settings -- [ ] Search "wifi" → Opens Wi-Fi settings -- [ ] (spot-check 5 more from the list) - -### App launch -- [ ] Type an app name (e.g. "notepad") → app appears → Enter → opens -- [ ] App icon shows (not blank) - -### Clipboard Manager -- [ ] Copy several items to clipboard -- [ ] Search "clipboard" → open Clipboard Manager -- [ ] Items appear in list -- [ ] Click an item or press Enter → item copied to clipboard -- [ ] Delete item with `Cmd+Delete` - -### Snippets -- [ ] Search "create snippet" → snippet creator opens -- [ ] Type keyword + content → save -- [ ] Search "search snippets" → snippet list appears -- [ ] Expand snippet in a text field - -### AI Chat -- [ ] Type a query → press Tab → AI chat opens -- [ ] Response streams in -- [ ] Follow-up questions work - -### Whisper Dictation -- [ ] Hold configured hotkey (default `Fn`) → listening state → speak → text typed into focused app - -### Text-to-Speech -- [ ] Select text in any app → search "read" → Enter → text is read aloud - -### Script Commands -- [ ] Search "create script" → opens script template in editor -- [ ] Add a sample script with `# @raycast.title` metadata -- [ ] Script appears in launcher and executes - -### Extensions -- [ ] Open Extension Store → browse extensions -- [ ] Install an extension → its commands appear in launcher -- [ ] Execute an extension command → renders correctly +## 9) Release Gate: “Everything Works on Windows” + +A Windows release is accepted only when all lines below are completed on at least one Windows 11 machine (and ideally one Windows 10 machine): + +1. Pass all rows in sections 1 through 8 with recorded evidence. +2. No blocker failures in clipboard/snippet/typing/replace pipelines. +3. No blocker failures in whisper/speak lifecycle transitions. +4. No blocker failures in extension install/run/oauth/menu-bar flows. +5. Packaged app validation passes for startup and updater behaviors. + +Current summary: +- Core Windows paths have been implemented for shared text insertion and snippet keyword expansion. +- Remaining work is runtime certification and any bugfixes found during that pass. diff --git a/scripts/build-native.js b/scripts/build-native.js index 94d54e7e..12638f83 100644 --- a/scripts/build-native.js +++ b/scripts/build-native.js @@ -185,6 +185,11 @@ if (process.platform === 'win32') { src: 'src/native/hotkey-hold-monitor.c', libs: ['user32'], }, + { + out: 'snippet-expander-win.exe', + src: 'src/native/snippet-expander-win.c', + libs: ['user32'], + }, ]; for (const { out, src, libs } of binaries) { diff --git a/src/main/main.ts b/src/main/main.ts index 84c1c224..167a854c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2007,6 +2007,12 @@ function parseHoldShortcutConfig(shortcut: string): { }; } +/** + * Stops the active whisper hold watcher process and clears tracking state. + * + * Why: + * - Avoids stale native listeners and duplicate release events. + */ function stopWhisperHoldWatcher(): void { if (!whisperHoldWatcherProcess) return; try { whisperHoldWatcherProcess.kill('SIGTERM'); } catch {} @@ -2015,6 +2021,9 @@ function stopWhisperHoldWatcher(): void { whisperHoldWatcherSeq = 0; } +/** + * Stops the dedicated Fn watcher used for whisper/speak toggle. + */ function stopFnSpeakToggleWatcher(): void { fnSpeakToggleWatcherEnabled = false; fnSpeakToggleIsPressed = false; @@ -2028,6 +2037,9 @@ function stopFnSpeakToggleWatcher(): void { fnSpeakToggleWatcherStdoutBuffer = ''; } +/** + * Stops one per-command Fn watcher and removes related buffers/timers. + */ function stopFnCommandWatcher(commandId: string): void { const timer = fnCommandWatcherRestartTimers.get(commandId); if (timer) { @@ -2037,11 +2049,13 @@ function stopFnCommandWatcher(commandId: string): void { const proc = fnCommandWatcherProcesses.get(commandId); if (proc) { try { proc.kill('SIGTERM'); } catch {} - fnCommandWatcherProcesses.delete(commandId); } - fnCommandWatcherStdoutBuffers.delete(commandId); + clearFnCommandWatcherRuntimeState(commandId); } +/** + * Stops all per-command Fn watchers and clears desired watcher configuration. + */ function stopAllFnCommandWatchers(): void { for (const commandId of Array.from(fnCommandWatcherProcesses.keys())) { stopFnCommandWatcher(commandId); @@ -2049,6 +2063,39 @@ function stopAllFnCommandWatchers(): void { fnCommandWatcherConfigs.clear(); } +/** + * Clears runtime process/buffer state for a single Fn command watcher. + */ +function clearFnCommandWatcherRuntimeState(commandId: string): void { + fnCommandWatcherProcesses.delete(commandId); + fnCommandWatcherStdoutBuffers.delete(commandId); +} + +/** + * Schedules restart for a per-command Fn watcher if configuration still exists. + */ +function scheduleFnCommandWatcherRestart(commandId: string, delayMs: number = 120): void { + if (!fnCommandWatcherConfigs.has(commandId)) return; + const existing = fnCommandWatcherRestartTimers.get(commandId); + if (existing) { + clearTimeout(existing); + fnCommandWatcherRestartTimers.delete(commandId); + } + const restartTimer = setTimeout(() => { + fnCommandWatcherRestartTimers.delete(commandId); + const desired = fnCommandWatcherConfigs.get(commandId); + if (!desired) return; + startFnCommandWatcher(commandId, desired); + }, delayMs); + fnCommandWatcherRestartTimers.set(commandId, restartTimer); +} + +/** + * Starts one per-command Fn watcher process for a configured shortcut. + * + * Why: + * - Enables Fn-based command triggers not representable via Electron accelerators. + */ function startFnCommandWatcher(commandId: string, shortcut: string): void { const configuredShortcut = String(fnCommandWatcherConfigs.get(commandId) || '').trim(); if (!configuredShortcut || configuredShortcut !== String(shortcut || '').trim()) return; @@ -2097,30 +2144,20 @@ function startFnCommandWatcher(commandId: string, shortcut: string): void { if (text) console.warn('[Hotkey][fn-watcher]', text); }); - const scheduleRestart = () => { - if (!fnCommandWatcherConfigs.has(commandId)) return; - const restartTimer = setTimeout(() => { - fnCommandWatcherRestartTimers.delete(commandId); - const desired = fnCommandWatcherConfigs.get(commandId); - if (!desired) return; - startFnCommandWatcher(commandId, desired); - }, 120); - fnCommandWatcherRestartTimers.set(commandId, restartTimer); - }; - proc.on('error', () => { - fnCommandWatcherProcesses.delete(commandId); - fnCommandWatcherStdoutBuffers.delete(commandId); - scheduleRestart(); + clearFnCommandWatcherRuntimeState(commandId); + scheduleFnCommandWatcherRestart(commandId); }); proc.on('exit', () => { - fnCommandWatcherProcesses.delete(commandId); - fnCommandWatcherStdoutBuffers.delete(commandId); - scheduleRestart(); + clearFnCommandWatcherRuntimeState(commandId); + scheduleFnCommandWatcherRestart(commandId); }); } +/** + * Starts the Fn-only watcher used by whisper/speak toggle behavior. + */ function startFnSpeakToggleWatcher(): void { if (fnSpeakToggleWatcherProcess || !fnSpeakToggleWatcherEnabled) return; const config = parseHoldShortcutConfig('Fn'); @@ -2207,6 +2244,9 @@ function startFnSpeakToggleWatcher(): void { }); } +/** + * Enables or disables Fn speak-toggle watcher based on settings and hotkey config. + */ function syncFnSpeakToggleWatcher(hotkeys: Record): void { // Do not start the CGEventTap-based Fn watcher during onboarding. // The tap requires Input Monitoring (and sometimes Accessibility) permission, @@ -2232,6 +2272,18 @@ function syncFnSpeakToggleWatcher(hotkeys: Record): void { startFnSpeakToggleWatcher(); } +/** + * Synchronizes per-command Fn-based watchers. + * + * What it does: + * - Tracks only commands whose accelerators include Fn. + * - Starts watchers for new/changed bindings. + * - Stops watchers for removed/changed bindings. + * + * Why: + * - GlobalShortcut cannot represent Fn-only behavior on macOS reliably, + * so we manage those shortcuts with native watchers. + */ function syncFnCommandWatchers(hotkeys: Record): void { const desired = new Map(); for (const [commandId, shortcutRaw] of Object.entries(hotkeys || {})) { @@ -2263,6 +2315,16 @@ function syncFnCommandWatchers(hotkeys: Record): void { } } +/** + * Ensures the hotkey hold monitor binary exists and returns its path. + * + * What it does: + * - Reuses packaged binary when present. + * - Builds from Swift source in development when binary is missing. + * + * Why: + * - Fn/hold behavior depends on native monitoring not provided by Electron. + */ function ensureWhisperHoldWatcherBinary(): string | null { const fs = require('fs'); const binaryPath = getNativeBinaryPath('hotkey-hold-monitor'); @@ -2294,6 +2356,15 @@ function ensureWhisperHoldWatcherBinary(): string | null { return null; } } + +/** + * Starts whisper hold-to-talk watcher for a specific shortcut sequence. + * + * What it does: + * - Starts native monitor with parsed modifier config. + * - Emits stop-listening when key release is observed. + * - Tracks sequence ID to avoid acting on stale watcher exits. + */ function startWhisperHoldWatcher(shortcut: string, holdSeq: number): void { if (whisperHoldWatcherProcess) return; const config = parseHoldShortcutConfig(shortcut); @@ -3282,6 +3353,48 @@ function setLauncherMode(mode: LauncherMode): void { } function captureFrontmostAppContext(): void { + if (process.platform === 'win32') { + try { + const { execFileSync } = require('child_process'); + const psScript = [ + '$sig = @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public static class NativeWin {', + ' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();', + ' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);', + '}', + '"@', + 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue | Out-Null', + '$hwnd = [NativeWin]::GetForegroundWindow()', + '$pid = 0', + '[void][NativeWin]::GetWindowThreadProcessId($hwnd, [ref]$pid)', + 'if ($pid -gt 0) {', + ' try {', + ' $p = Get-Process -Id $pid -ErrorAction Stop', + ' $name = [string]$p.ProcessName', + ' $path = ""', + ' try { $path = [string]$p.MainModule.FileName } catch {}', + ' if ($name -and $name -ne "SuperCmd" -and $name -ne "electron") {', + ' Write-Output ($name + "|||" + $path)', + ' }', + ' } catch {}', + '}', + ].join('; '); + const output = String( + execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { encoding: 'utf-8' }) || '' + ).trim(); + if (output) { + const [name, appPath] = output.split('|||'); + if (name && name !== 'SuperCmd' && name !== 'electron') { + lastFrontmostApp = { name, path: appPath || '' }; + } + } + } catch { + // keep previously captured value + } + return; + } if (process.platform !== 'darwin') return; try { const { execFileSync } = require('child_process'); @@ -3468,6 +3581,27 @@ async function activateLastFrontmostApp(): Promise { const { promisify } = require('util'); const execFileAsync = promisify(execFile); + if (process.platform === 'win32') { + try { + const name = String(lastFrontmostApp.name || '').trim(); + if (name) { + const escapedName = name.replace(/'/g, "''"); + const psScript = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.Interaction]::AppActivate('${escapedName}') | Out-Null`; + await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { windowsHide: true } as any); + return true; + } + } catch {} + + try { + const appPath = String(lastFrontmostApp.path || '').trim(); + if (appPath) { + await shell.openPath(appPath); + return true; + } + } catch {} + return false; + } + try { if (lastFrontmostApp.bundleId) { await execFileAsync('osascript', [ @@ -3512,6 +3646,32 @@ async function typeTextDirectly(text: string): Promise { const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); + + if (process.platform === 'win32') { + const escaped = value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\{/g, '{{}') + .replace(/\}/g, '{}}') + .replace(/\+/g, '{+}') + .replace(/\^/g, '{^}') + .replace(/%/g, '{%}') + .replace(/~/g, '{~}') + .replace(/\(/g, '{(}') + .replace(/\)/g, '{)}') + .replace(/\[/g, '{[}') + .replace(/\]/g, '{]}') + .replace(/\n/g, '{ENTER}'); + try { + const psScript = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped.replace(/'/g, "''")}')`; + await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { windowsHide: true } as any); + return true; + } catch (error) { + console.error('Direct keystroke fallback failed:', error); + return false; + } + } + const escaped = value .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') @@ -3541,10 +3701,21 @@ async function pasteTextToActiveApp(text: string): Promise { try { systemClipboard.writeText(value); - await execFileAsync('osascript', [ - '-e', - 'tell application "System Events" to keystroke "v" using command down', - ]); + if (process.platform === 'win32') { + await execFileAsync('powershell', [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait("^v")', + ], { windowsHide: true } as any); + } else { + await execFileAsync('osascript', [ + '-e', + 'tell application "System Events" to keystroke "v" using command down', + ]); + } setTimeout(() => { try { systemClipboard.writeText(previousClipboardText); @@ -3567,14 +3738,20 @@ async function replaceTextDirectly(previousText: string, nextText: string): Prom try { if (prev.length > 0) { - const script = ` - tell application "System Events" - repeat ${prev.length} times - key code 51 - end repeat - end tell - `; - await execFileAsync('osascript', ['-e', script]); + if (process.platform === 'win32') { + const keys = '{BACKSPACE}'.repeat(Math.min(prev.length, 5000)); + const psScript = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${keys}')`; + await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { windowsHide: true } as any); + } else { + const script = ` + tell application "System Events" + repeat ${prev.length} times + key code 51 + end repeat + end tell + `; + await execFileAsync('osascript', ['-e', script]); + } } if (next.length > 0) { return await typeTextDirectly(next); @@ -3596,14 +3773,20 @@ async function replaceTextViaBackspaceAndPaste(previousText: string, nextText: s try { if (prev.length > 0) { - const script = ` - tell application "System Events" - repeat ${prev.length} times - key code 51 - end repeat - end tell - `; - await execFileAsync('osascript', ['-e', script]); + if (process.platform === 'win32') { + const keys = '{BACKSPACE}'.repeat(Math.min(prev.length, 5000)); + const psScript = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${keys}')`; + await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { windowsHide: true } as any); + } else { + const script = ` + tell application "System Events" + repeat ${prev.length} times + key code 51 + end repeat + end tell + `; + await execFileAsync('osascript', ['-e', script]); + } await new Promise((resolve) => setTimeout(resolve, 18)); } if (next.length > 0) { @@ -3641,6 +3824,23 @@ async function hideAndPaste(): Promise { // Small delay to let the target app gain focus await new Promise(resolve => setTimeout(resolve, 200)); + if (process.platform === 'win32') { + try { + await execFileAsync('powershell', [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait("^v")', + ], { windowsHide: true } as any); + return true; + } catch (e) { + console.error('Failed to simulate paste keystroke:', e); + return false; + } + } + try { await execFileAsync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']); return true; @@ -3683,16 +3883,26 @@ async function expandSnippetKeywordInPlace(keyword: string, delimiter: string): const { promisify } = require('util'); const execFileAsync = promisify(execFile); - const script = ` - tell application "System Events" - repeat ${backspaceCount} times - key code 51 - end repeat - keystroke "v" using command down - end tell - `; + if (process.platform === 'win32') { + const keys = '{BACKSPACE}'.repeat(Math.min(backspaceCount, 5000)); + const psScript = [ + 'Add-Type -AssemblyName System.Windows.Forms', + `[System.Windows.Forms.SendKeys]::SendWait('${keys}')`, + '[System.Windows.Forms.SendKeys]::SendWait("^v")', + ].join('; '); + await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { windowsHide: true } as any); + } else { + const script = ` + tell application "System Events" + repeat ${backspaceCount} times + key code 51 + end repeat + keystroke "v" using command down + end tell + `; - await execFileAsync('osascript', ['-e', script]); + await execFileAsync('osascript', ['-e', script]); + } // Restore user's clipboard after insertion. setTimeout(() => { @@ -6996,6 +7206,41 @@ app.whenReady().then(async () => { try { return await downloadUrl(url); } catch (primaryErr: any) { + if (process.platform === 'win32') { + const psOutput = await new Promise((resolve, reject) => { + const escapedUrl = String(url || '').replace(/'/g, "''"); + const psScript = [ + `$u='${escapedUrl}'`, + '$resp = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60', + '$ms = New-Object System.IO.MemoryStream', + '$resp.RawContentStream.CopyTo($ms)', + '$bytes = $ms.ToArray()', + '[Console]::Out.Write([Convert]::ToBase64String($bytes))', + ].join('; '); + execFile( + 'powershell', + ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], + { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024, windowsHide: true } as any, + (err: Error | null, stdout: string, stderr: string) => { + if (err) { + reject( + new Error( + `HTTP download failed (${primaryErr?.message || 'unknown'}) and PowerShell fallback failed (${stderr || err.message})` + ) + ); + return; + } + try { + resolve(new Uint8Array(Buffer.from(String(stdout || '').trim(), 'base64'))); + } catch (decodeErr: any) { + reject(new Error(`PowerShell fallback decode failed: ${decodeErr?.message || 'unknown'}`)); + } + } + ); + }); + return psOutput; + } + const curlOutput = await new Promise((resolve, reject) => { execFile( '/usr/bin/curl', @@ -7039,6 +7284,57 @@ app.whenReady().then(async () => { // Get installed applications ipcMain.handle('get-applications', async (_event: any, targetPath?: string) => { + if (process.platform === 'win32') { + const { execFileSync } = require('child_process'); + const commands = await getAvailableCommands(); + let apps = commands + .filter((c) => c.category === 'app') + .map((c) => ({ + name: c.title, + path: c.path || '', + bundleId: undefined, + })); + + if (targetPath && typeof targetPath === 'string') { + try { + const escaped = String(targetPath).replace(/'/g, "''"); + const psScript = [ + `$target='${escaped}'`, + '$ext = [System.IO.Path]::GetExtension($target)', + 'if (-not $ext) { return }', + "$progId = ''", + 'try {', + " $userChoice = Get-ItemProperty -Path (\"Registry::HKEY_CURRENT_USER\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\FileExts\\\\\" + $ext + \"\\\\UserChoice\") -ErrorAction Stop", + " $progId = [string]$userChoice.ProgId", + '} catch {}', + 'if (-not $progId) {', + ' try { $progId = [string](Get-ItemProperty -Path ("Registry::HKEY_CLASSES_ROOT\\\\" + $ext) -ErrorAction Stop)."(default)" } catch {}', + '}', + 'if (-not $progId) { return }', + "$cmd=''", + 'try { $cmd = [string](Get-ItemProperty -Path ("Registry::HKEY_CLASSES_ROOT\\\\" + $progId + \"\\\\shell\\\\open\\\\command\") -ErrorAction Stop).\"(default)\" } catch {}', + 'if (-not $cmd) { return }', + "$exe=''", + "if ($cmd -match '^\\s*\"([^\"]+)\"') { $exe = $matches[1] } elseif ($cmd -match '^\\s*([^\\s]+)') { $exe = $matches[1] }", + 'if ($exe) { Write-Output $exe }', + ].join('; '); + const resolvedPath = String( + execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { encoding: 'utf-8' }) || '' + ).trim(); + if (resolvedPath) { + const normalized = resolvedPath.toLowerCase(); + apps = apps.filter((a) => String(a.path || '').toLowerCase() === normalized); + } else { + apps = []; + } + } catch { + apps = []; + } + } + + return apps; + } + const { execFileSync } = require('child_process'); const fsNative = require('fs'); @@ -7109,6 +7405,46 @@ return appURL's |path|() as text`, // Get default application for a file/URL ipcMain.handle('get-default-application', async (_event: any, filePath: string) => { + if (process.platform === 'win32') { + try { + const { execFileSync } = require('child_process'); + const escaped = String(filePath || '').replace(/'/g, "''"); + const psScript = [ + `$target='${escaped}'`, + '$ext = [System.IO.Path]::GetExtension($target)', + 'if (-not $ext) { throw "No extension found" }', + "$progId = ''", + 'try {', + " $userChoice = Get-ItemProperty -Path (\"Registry::HKEY_CURRENT_USER\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\FileExts\\\\\" + $ext + \"\\\\UserChoice\") -ErrorAction Stop", + " $progId = [string]$userChoice.ProgId", + '} catch {}', + 'if (-not $progId) {', + ' try { $progId = [string](Get-ItemProperty -Path ("Registry::HKEY_CLASSES_ROOT\\\\" + $ext) -ErrorAction Stop)."(default)" } catch {}', + '}', + 'if (-not $progId) { throw "No ProgId found" }', + "$cmd=''", + 'try { $cmd = [string](Get-ItemProperty -Path ("Registry::HKEY_CLASSES_ROOT\\\\" + $progId + \"\\\\shell\\\\open\\\\command\") -ErrorAction Stop).\"(default)\" } catch {}', + 'if (-not $cmd) { throw "No open command found" }', + "$exe=''", + "if ($cmd -match '^\\s*\"([^\"]+)\"') { $exe = $matches[1] } elseif ($cmd -match '^\\s*([^\\s]+)') { $exe = $matches[1] }", + 'if (-not $exe) { throw "Unable to resolve executable" }', + '$name = [System.IO.Path]::GetFileNameWithoutExtension($exe)', + 'Write-Output ($name + "|||" + $exe + "|||")', + ].join('; '); + const result = String( + execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { encoding: 'utf-8' }) || '' + ).trim(); + const [name, appPath] = result.split('|||'); + if (!name || !appPath) { + throw new Error('No default application found'); + } + return { name, path: appPath, bundleId: undefined }; + } catch (e: any) { + console.error('get-default-application error:', e); + throw new Error(`No default application found for: ${filePath}`); + } + } + try { const { execSync } = require('child_process'); // Use Launch Services via AppleScript to find default app @@ -7136,6 +7472,43 @@ return appURL's |path|() as text`, // Get frontmost application ipcMain.handle('get-frontmost-application', async () => { + if (process.platform === 'win32') { + try { + const { execFileSync } = require('child_process'); + const psScript = [ + '$sig = @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public static class NativeWin {', + ' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();', + ' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);', + '}', + '"@', + 'Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue | Out-Null', + '$hwnd = [NativeWin]::GetForegroundWindow()', + '$pid = 0', + '[void][NativeWin]::GetWindowThreadProcessId($hwnd, [ref]$pid)', + 'if ($pid -gt 0) {', + ' try {', + ' $p = Get-Process -Id $pid -ErrorAction Stop', + ' $name = [string]$p.ProcessName', + ' $path = \"\"', + ' try { $path = [string]$p.MainModule.FileName } catch {}', + ' Write-Output ($name + \"|||\" + $path)', + ' } catch {}', + '}', + ].join('; '); + const output = String( + execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { encoding: 'utf-8' }) || '' + ).trim(); + if (output) { + const [name, appPath] = output.split('|||'); + return { name: name || 'Unknown', path: appPath || '' }; + } + } catch {} + return { name: 'SuperCmd', path: '', bundleId: 'com.supercmd' }; + } + try { const { execSync } = require('child_process'); const script = ` @@ -7157,6 +7530,9 @@ return appURL's |path|() as text`, // Run AppleScript ipcMain.handle('run-applescript', async (_event: any, script: string) => { + if (process.platform !== 'darwin') { + throw new Error('runAppleScript is only supported on macOS'); + } try { const { spawnSync } = require('child_process'); const proc = spawnSync('/usr/bin/osascript', ['-l', 'AppleScript'], { @@ -8354,6 +8730,17 @@ return appURL's |path|() as text`, // ─── IPC: Native Color Picker ────────────────────────────────── + /** + * Opens the platform color picker with single-flight protection. + * + * What it does: + * - Reuses one in-flight picker promise to avoid overlapping dialogs. + * - Prevents launcher blur-hide while picker is active. + * + * Why: + * - Concurrent invocations can cause duplicate dialogs and inconsistent + * blur state; this keeps behavior stable across platforms. + */ ipcMain.handle('native-pick-color', async () => { if (nativeColorPickerPromise) { return nativeColorPickerPromise; diff --git a/src/main/platform/windows.ts b/src/main/platform/windows.ts index 3fc5f766..a985dfda 100644 --- a/src/main/platform/windows.ts +++ b/src/main/platform/windows.ts @@ -136,10 +136,38 @@ export const windows: PlatformCapabilities = { } }, - spawnSnippetExpander(_keywords: string[]): ChildProcess | null { - // Snippet expansion requires a system-wide keyboard hook. - // Windows implementation will be added in a follow-up PR. - return null; + spawnSnippetExpander(keywords: string[]): ChildProcess | null { + const fs = require('fs'); + const { spawn } = require('child_process'); + + const filtered = Array.from( + new Set( + (keywords || []) + .map((kw) => String(kw || '').trim().toLowerCase()) + .filter(Boolean) + ) + ); + if (filtered.length === 0) return null; + + const binaryPath = getNativeBinaryPath('snippet-expander-win.exe'); + if (!fs.existsSync(binaryPath)) { + console.warn( + '[Windows][snippet] snippet-expander-win.exe not found.', + 'Run `npm run build:native` to compile it.', + binaryPath + ); + return null; + } + + try { + return spawn( + binaryPath, + [JSON.stringify(filtered)], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + } catch { + return null; + } }, async pickColor(): Promise { diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index b8eeede7..a91600f5 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -446,6 +446,75 @@ function shebangArgs(firstLine: string): string[] { return body.split(/\s+/g).filter(Boolean); } +function commandExists(bin: string): boolean { + const candidate = String(bin || '').trim(); + if (!candidate) return false; + try { + const { spawnSync } = require('child_process'); + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(whichCmd, [candidate], { stdio: 'ignore' }); + return Number(result?.status) === 0; + } catch { + return false; + } +} + +function resolveScriptSpawn( + scriptPath: string, + shebang: string[], + args: string[] +): { command: string; args: string[] } { + const ext = path.extname(scriptPath).toLowerCase(); + + if (shebang.length > 0) { + let command = shebang[0]; + let commandArgs = shebang.slice(1); + if (path.basename(command).toLowerCase() === 'env' && commandArgs.length > 0) { + command = commandArgs[0]; + commandArgs = commandArgs.slice(1); + } + return { command, args: [...commandArgs, scriptPath, ...args] }; + } + + if (process.platform === 'win32') { + if (ext === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args], + }; + } + if (ext === '.cmd' || ext === '.bat') { + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', scriptPath, ...args], + }; + } + if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { + return { + command: 'node', + args: [scriptPath, ...args], + }; + } + if (ext === '.py') { + const pyLauncher = commandExists('py') ? 'py' : 'python'; + const pyArgs = pyLauncher === 'py' ? ['-3', scriptPath, ...args] : [scriptPath, ...args]; + return { command: pyLauncher, args: pyArgs }; + } + if (ext === '.sh') { + return { + command: 'bash', + args: [scriptPath, ...args], + }; + } + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args], + }; + } + + return { command: '/bin/bash', args: [scriptPath, ...args] }; +} + function buildScriptArgs( cmd: ScriptCommandInfo, argumentValues?: Record @@ -499,7 +568,9 @@ export async function executeScriptCommand( const env = { ...process.env, - PATH: `${process.env.PATH || ''}:/usr/local/bin`, + PATH: process.platform === 'win32' + ? String(process.env.PATH || '') + : `${process.env.PATH || ''}:/usr/local/bin`, RAYCAST_TITLE: cmd.title, RAYCAST_MODE: cmd.mode, RAYCAST_COMMAND_ID: cmd.id, @@ -509,12 +580,9 @@ export async function executeScriptCommand( const cwd = cmd.currentDirectoryPath || cmd.scriptDir; - const spawnCommand = - shebang.length > 0 ? shebang[0] : '/bin/bash'; - const spawnArgs = - shebang.length > 0 - ? [...shebang.slice(1), cmd.scriptPath, ...args] - : [cmd.scriptPath, ...args]; + const spawnSpec = resolveScriptSpawn(cmd.scriptPath, shebang, args); + const spawnCommand = spawnSpec.command; + const spawnArgs = spawnSpec.args; const run = await new Promise<{ stdout: string; @@ -622,6 +690,22 @@ export async function executeScriptCommand( function buildTemplateScript(title: string): string { const escapedTitle = title.replace(/"/g, '\\"'); + if (process.platform === 'win32') { + return `# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title ${escapedTitle} +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.packageName SuperCmd +# @raycast.icon 💡 + +# Documentation: +# @raycast.description Describe what this command does + +Write-Output "Hello from ${escapedTitle}" +`; + } return `#!/bin/bash # Required parameters: @@ -643,10 +727,11 @@ echo "Hello from ${escapedTitle}" export function createScriptCommandTemplate(): { scriptPath: string; scriptsDir: string } { const scriptsDir = getSuperCmdScriptsDir(); const baseName = 'custom-script-command'; - let targetPath = path.join(scriptsDir, `${baseName}.sh`); + const ext = process.platform === 'win32' ? '.ps1' : '.sh'; + let targetPath = path.join(scriptsDir, `${baseName}${ext}`); let seq = 2; while (fs.existsSync(targetPath)) { - targetPath = path.join(scriptsDir, `${baseName}-${seq}.sh`); + targetPath = path.join(scriptsDir, `${baseName}-${seq}${ext}`); seq += 1; } @@ -678,10 +763,11 @@ export function ensureSampleScriptCommand(): { const sampleTitle = 'Sample Script Command'; const sampleBaseName = 'sample-script-command'; - let targetPath = path.join(scriptsDir, `${sampleBaseName}.sh`); + const ext = process.platform === 'win32' ? '.ps1' : '.sh'; + let targetPath = path.join(scriptsDir, `${sampleBaseName}${ext}`); let seq = 2; while (fs.existsSync(targetPath)) { - targetPath = path.join(scriptsDir, `${sampleBaseName}-${seq}.sh`); + targetPath = path.join(scriptsDir, `${sampleBaseName}-${seq}${ext}`); seq += 1; } diff --git a/src/native/snippet-expander-win.c b/src/native/snippet-expander-win.c new file mode 100644 index 00000000..82a7d2ee --- /dev/null +++ b/src/native/snippet-expander-win.c @@ -0,0 +1,304 @@ +/** + * snippet-expander-win.c + * + * Windows global snippet keyword watcher. + * + * Args: + * + * + * Emits newline-delimited JSON payloads to stdout: + * {"ready":true} + * {"keyword":"sig","delimiter":" "} + */ + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include + +#define MAX_KEYWORDS 512 +#define MAX_KEYWORD_LEN 128 +#define MAX_TOKEN_LEN 512 + +static HHOOK g_hook = NULL; +static char g_keywords[MAX_KEYWORDS][MAX_KEYWORD_LEN + 1]; +static int g_keyword_count = 0; +static int g_max_keyword_len = 1; + +static unsigned char g_allowed[256]; +static unsigned char g_delimiters[256]; + +static char g_token[MAX_TOKEN_LEN + 1]; +static int g_token_len = 0; + +static void emit_ready(void) { + printf("{\"ready\":true}\n"); + fflush(stdout); +} + +static void emit_error(const char* msg) { + printf("{\"error\":\"%s\"}\n", msg ? msg : "unknown"); + fflush(stdout); +} + +static int is_modifier_down(void) { + if (GetAsyncKeyState(VK_CONTROL) & 0x8000) return 1; + if (GetAsyncKeyState(VK_MENU) & 0x8000) return 1; + if (GetAsyncKeyState(VK_LWIN) & 0x8000) return 1; + if (GetAsyncKeyState(VK_RWIN) & 0x8000) return 1; + return 0; +} + +static void clear_token(void) { + g_token_len = 0; + g_token[0] = '\0'; +} + +static void trim_token_to_max(void) { + if (g_token_len <= g_max_keyword_len) return; + int keep = g_max_keyword_len; + int remove = g_token_len - keep; + memmove(g_token, g_token + remove, (size_t)keep); + g_token_len = keep; + g_token[g_token_len] = '\0'; +} + +static int keyword_index(const char* text) { + if (!text || !*text) return -1; + for (int i = 0; i < g_keyword_count; i++) { + if (strcmp(g_keywords[i], text) == 0) return i; + } + return -1; +} + +static void json_escape_char(char c, char* out, size_t out_size) { + if (!out || out_size == 0) return; + if (c == '\\') snprintf(out, out_size, "\\\\"); + else if (c == '"') snprintf(out, out_size, "\\\""); + else if (c == '\n') snprintf(out, out_size, "\\n"); + else if (c == '\r') snprintf(out, out_size, "\\r"); + else if (c == '\t') snprintf(out, out_size, "\\t"); + else snprintf(out, out_size, "%c", c); +} + +static void emit_keyword(const char* keyword, char delimiter) { + char delim_escaped[16] = {0}; + json_escape_char(delimiter, delim_escaped, sizeof(delim_escaped)); + printf("{\"keyword\":\"%s\",\"delimiter\":\"%s\"}\n", keyword, delim_escaped); + fflush(stdout); +} + +static void process_char(char raw) { + unsigned char c = (unsigned char)tolower((unsigned char)raw); + + if (g_allowed[c]) { + if (g_token_len < MAX_TOKEN_LEN) { + g_token[g_token_len++] = (char)c; + g_token[g_token_len] = '\0'; + } + trim_token_to_max(); + + if (keyword_index(g_token) >= 0) { + emit_keyword(g_token, '\0'); + clear_token(); + } + return; + } + + if (g_delimiters[c]) { + if (g_token_len > 0 && keyword_index(g_token) >= 0) { + emit_keyword(g_token, (char)c); + } + clear_token(); + return; + } + + clear_token(); +} + +static void seed_charsets(void) { + memset(g_allowed, 0, sizeof(g_allowed)); + memset(g_delimiters, 0, sizeof(g_delimiters)); + + for (int c = 'a'; c <= 'z'; c++) g_allowed[(unsigned char)c] = 1; + for (int c = '0'; c <= '9'; c++) g_allowed[(unsigned char)c] = 1; + g_allowed[(unsigned char)'-'] = 1; + g_allowed[(unsigned char)'_'] = 1; + + const char* delimiters = " \t\r\n.,!?;:()[]{}<>/\\|@#$%^&*+=`~\"'"; + for (const char* p = delimiters; *p; p++) { + g_delimiters[(unsigned char)(*p)] = 1; + } +} + +static void apply_keyword_chars_to_charsets(void) { + for (int i = 0; i < g_keyword_count; i++) { + const char* kw = g_keywords[i]; + for (int j = 0; kw[j]; j++) { + unsigned char c = (unsigned char)tolower((unsigned char)kw[j]); + if (c == '\r' || c == '\n' || c == '\t' || c == ' ') continue; + g_allowed[c] = 1; + g_delimiters[c] = 0; + } + } +} + +static int append_keyword(const char* src) { + if (!src) return 0; + if (g_keyword_count >= MAX_KEYWORDS) return 0; + + char buf[MAX_KEYWORD_LEN + 1] = {0}; + int n = 0; + for (int i = 0; src[i] && n < MAX_KEYWORD_LEN; i++) { + unsigned char c = (unsigned char)tolower((unsigned char)src[i]); + buf[n++] = (char)c; + } + buf[n] = '\0'; + if (n == 0) return 0; + + for (int i = 0; i < g_keyword_count; i++) { + if (strcmp(g_keywords[i], buf) == 0) return 1; + } + + strcpy(g_keywords[g_keyword_count++], buf); + if (n > g_max_keyword_len) g_max_keyword_len = n; + return 1; +} + +static int parse_keywords_json(const char* json) { + if (!json) return 0; + int in_string = 0; + int escaped = 0; + char current[MAX_KEYWORD_LEN + 1] = {0}; + int cur_len = 0; + + for (const char* p = json; *p; p++) { + char ch = *p; + if (!in_string) { + if (ch == '"') { + in_string = 1; + escaped = 0; + cur_len = 0; + current[0] = '\0'; + } + continue; + } + + if (escaped) { + char out = ch; + if (ch == 'n') out = '\n'; + else if (ch == 'r') out = '\r'; + else if (ch == 't') out = '\t'; + if (cur_len < MAX_KEYWORD_LEN) { + current[cur_len++] = out; + current[cur_len] = '\0'; + } + escaped = 0; + continue; + } + + if (ch == '\\') { + escaped = 1; + continue; + } + + if (ch == '"') { + in_string = 0; + append_keyword(current); + continue; + } + + if (cur_len < MAX_KEYWORD_LEN) { + current[cur_len++] = ch; + current[cur_len] = '\0'; + } + } + + return g_keyword_count > 0; +} + +static void process_key_event(DWORD vk, DWORD scan_code) { + if (vk == VK_BACK) { + if (g_token_len > 0) { + g_token_len--; + g_token[g_token_len] = '\0'; + } + return; + } + + if (is_modifier_down()) { + clear_token(); + return; + } + + BYTE key_state[256]; + memset(key_state, 0, sizeof(key_state)); + if (!GetKeyboardState(key_state)) { + clear_token(); + return; + } + + key_state[vk] |= 0x80; + + WCHAR wbuf[8]; + HKL layout = GetKeyboardLayout(0); + int rc = ToUnicodeEx((UINT)vk, (UINT)scan_code, key_state, wbuf, 8, 0, layout); + if (rc <= 0) { + if (rc < 0) { + BYTE empty_state[256] = {0}; + WCHAR dummy[8]; + ToUnicodeEx((UINT)vk, (UINT)scan_code, empty_state, dummy, 8, 0, layout); + } + return; + } + + for (int i = 0; i < rc; i++) { + WCHAR wc = wbuf[i]; + if (wc <= 0 || wc > 127) { + clear_token(); + continue; + } + process_char((char)wc); + } +} + +static LRESULT CALLBACK keyboard_hook(int nCode, WPARAM wp, LPARAM lp) { + if (nCode == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)) { + KBDLLHOOKSTRUCT* kb = (KBDLLHOOKSTRUCT*)lp; + process_key_event(kb->vkCode, kb->scanCode); + } + return CallNextHookEx(g_hook, nCode, wp, lp); +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + emit_error("Usage: snippet-expander-win "); + return 1; + } + + seed_charsets(); + if (!parse_keywords_json(argv[1])) { + emit_error("Invalid or empty keywords JSON"); + return 1; + } + apply_keyword_chars_to_charsets(); + + g_hook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_hook, GetModuleHandleW(NULL), 0); + if (!g_hook) { + emit_error("SetWindowsHookEx failed"); + return 2; + } + + emit_ready(); + + MSG msg; + while (GetMessage(&msg, NULL, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + UnhookWindowsHookEx(g_hook); + return 0; +} diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 4f2c6e0e..e57c2b2b 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -649,6 +649,19 @@ export const Clipboard = { keystroke "v" using command down end tell` ); + } else if (electron?.platform === 'win32' && electron?.execCommand) { + await electron.execCommand( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait("^v")', + ], + { shell: false } + ); } } catch (e) { console.error('Clipboard paste error:', e); @@ -1216,8 +1229,25 @@ export async function open(target: string, application?: string | Application): const electron = (window as any).electron; if (application) { const appName = typeof application === 'string' ? application : application.name; - // Use 'open -a' to open with a specific application if (electron?.execCommand) { + if (electron?.platform === 'win32') { + const escapedApp = String(appName || '').replace(/'/g, "''"); + const escapedTarget = String(target || '').replace(/'/g, "''"); + await electron.execCommand( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + `Start-Process -FilePath '${escapedApp}' -ArgumentList @('${escapedTarget}')`, + ], + { shell: false } + ); + return; + } + // macOS compatibility path await electron.execCommand('open', ['-a', appName, target]); return; } From bd48ed79d92da19e21da620810dceec523c2495d Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 08:46:10 -0600 Subject: [PATCH 44/49] Include remaining settings/refactor changes and tests for Windows validation pull --- src/main/__tests__/settings-store.test.ts | 106 ++++ src/main/settings-store.ts | 233 ++++--- src/renderer/src/App.tsx | 655 ++++++++++++-------- src/renderer/src/settings/ExtensionsTab.tsx | 35 ++ src/renderer/src/settings/GeneralTab.tsx | 209 +++++-- 5 files changed, 839 insertions(+), 399 deletions(-) create mode 100644 src/main/__tests__/settings-store.test.ts diff --git a/src/main/__tests__/settings-store.test.ts b/src/main/__tests__/settings-store.test.ts new file mode 100644 index 00000000..95e12aa7 --- /dev/null +++ b/src/main/__tests__/settings-store.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +let mockUserDataPath = ''; + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') return mockUserDataPath; + return mockUserDataPath; + }, + }, +})); + +import { + getOAuthToken, + loadSettings, + removeOAuthToken, + resetSettingsCache, + saveSettings, + setOAuthToken, +} from '../settings-store'; + +function getSettingsFilePath(): string { + return path.join(mockUserDataPath, 'settings.json'); +} + +describe('settings-store', () => { + beforeEach(() => { + mockUserDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-settings-test-')); + resetSettingsCache(); + }); + + afterEach(() => { + resetSettingsCache(); + try { + fs.rmSync(mockUserDataPath, { recursive: true, force: true }); + } catch {} + }); + + it('loads defaults when settings file is missing', () => { + const settings = loadSettings(); + + expect(settings.globalShortcut).toBe(process.platform === 'win32' ? 'Ctrl+Space' : 'Alt+Space'); + expect(settings.commandHotkeys['system-supercmd-whisper-speak-toggle']).toBe( + process.platform === 'win32' ? 'Ctrl+Shift+Space' : 'Fn' + ); + expect(settings.baseColor).toBe('#101113'); + expect(settings.commandAliases).toEqual({}); + }); + + it('migrates legacy whisper hotkey keys and trims aliases', () => { + const legacy = { + commandHotkeys: { + 'system-supercmd-whisper-toggle': 'Fn', + }, + commandAliases: { + ' cmd-one ': ' alias-one ', + '': 'ignored', + 'cmd-two': '', + }, + hasSeenOnboarding: false, + }; + + fs.writeFileSync(getSettingsFilePath(), JSON.stringify(legacy, null, 2)); + const settings = loadSettings(); + + expect(settings.commandHotkeys['system-supercmd-whisper-speak-toggle']).toBe('Fn'); + expect(settings.commandHotkeys['system-supercmd-whisper-toggle']).toBeUndefined(); + expect(settings.commandAliases).toEqual({ 'cmd-one': 'alias-one' }); + expect(settings.hasSeenOnboarding).toBe(false); + }); + + it('saves settings patch and can reload persisted values', () => { + const saved = saveSettings({ + openAtLogin: true, + baseColor: '#abcdef', + commandAliases: { 'alpha-command': 'alpha' }, + }); + + expect(saved.openAtLogin).toBe(true); + expect(saved.baseColor).toBe('#abcdef'); + + resetSettingsCache(); + const reloaded = loadSettings(); + expect(reloaded.openAtLogin).toBe(true); + expect(reloaded.baseColor).toBe('#abcdef'); + expect(reloaded.commandAliases['alpha-command']).toBe('alpha'); + }); + + it('stores and removes oauth tokens independently of settings', () => { + setOAuthToken('notion', { + accessToken: 'token-123', + tokenType: 'Bearer', + obtainedAt: new Date('2026-02-21T00:00:00.000Z').toISOString(), + }); + + const token = getOAuthToken('notion'); + expect(token?.accessToken).toBe('token-123'); + + removeOAuthToken('notion'); + expect(getOAuthToken('notion')).toBeNull(); + }); +}); diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 7d53c9ac..a91b776c 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -1,8 +1,16 @@ /** * Settings Store * - * Simple JSON-file persistence for app settings. - * Stored at ~/Library/Application Support/SuperCmd/settings.json + * What this file is: + * - The single persistence layer for main-process settings. + * + * What it does: + * - Loads/saves normalized settings JSON. + * - Handles backward-compatible migrations for old keys. + * - Persists OAuth tokens separately from user settings. + * + * Why we need it: + * - Keeps settings behavior deterministic across restarts and app versions. */ import { app } from 'electron'; @@ -95,7 +103,8 @@ const DEFAULT_AI_SETTINGS: AISettings = { ollamaBaseUrl: 'http://localhost:11434', defaultModel: '', speechCorrectionModel: '', - speechToTextModel: 'native', + // "native" speech recognition is macOS-only; Windows falls back to empty default. + speechToTextModel: process.platform === 'win32' ? '' : 'native', speechLanguage: 'en-US', textToSpeechModel: 'edge-tts', edgeTtsVoice: 'en-US-EricNeural', @@ -109,17 +118,24 @@ const DEFAULT_AI_SETTINGS: AISettings = { openaiCompatibleModel: '', }; +// Alt+Space opens the system menu on Windows, so Ctrl+Space is the portable default there. +const DEFAULT_GLOBAL_SHORTCUT = process.platform === 'win32' ? 'Ctrl+Space' : 'Alt+Space'; + +// Use Ctrl-based command hotkeys on Windows to match platform conventions. +const MOD = process.platform === 'win32' ? 'Ctrl' : 'Command'; + const DEFAULT_SETTINGS: AppSettings = { - globalShortcut: 'Alt+Space', + globalShortcut: DEFAULT_GLOBAL_SHORTCUT, openAtLogin: false, disabledCommands: [], enabledCommands: [], customExtensionFolders: [], commandHotkeys: { - 'system-cursor-prompt': 'Command+Shift+K', - 'system-supercmd-whisper': 'Command+Shift+W', - 'system-supercmd-whisper-speak-toggle': 'Fn', - 'system-supercmd-speak': 'Command+Shift+S', + 'system-cursor-prompt': `${MOD}+Shift+K`, + 'system-supercmd-whisper': `${MOD}+Shift+W`, + // Fn is hardware-handled on Windows, so a regular shortcut is required. + 'system-supercmd-whisper-speak-toggle': process.platform === 'win32' ? 'Ctrl+Shift+Space' : 'Fn', + 'system-supercmd-speak': `${MOD}+Shift+S`, }, commandAliases: {}, pinnedCommands: [], @@ -139,12 +155,18 @@ const DEFAULT_SETTINGS: AppSettings = { let settingsCache: AppSettings | null = null; +/** + * Keeps font-size values constrained to supported enum values. + */ function normalizeFontSize(value: any): AppFontSize { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'small' || normalized === 'large') return normalized; return 'medium'; } +/** + * Coerces color values to normalized 6-char lowercase hex, with fallback. + */ function normalizeBaseColor(value: any): string { const raw = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw.toLowerCase(); @@ -155,6 +177,9 @@ function normalizeBaseColor(value: any): string { return DEFAULT_SETTINGS.baseColor; } +/** + * Normalizes persisted hyper-key source aliases from older builds. + */ function normalizeHyperKeySource(value: any): AppSettings['hyperKeySource'] { const raw = String(value || '').trim().toLowerCase(); const map: Record = { @@ -193,6 +218,9 @@ function normalizeHyperKeySource(value: any): AppSettings['hyperKeySource'] { return 'none'; } +/** + * Normalizes quick-press behavior aliases to current enum values. + */ function normalizeHyperKeyQuickPressAction(value: any): 'toggle-caps-lock' | 'escape' | 'none' { const raw = String(value || '').trim().toLowerCase(); if (raw === 'escape' || raw === 'esc' || raw === 'trigger-esc' || raw === 'triggers-esc') return 'escape'; @@ -204,78 +232,114 @@ function getSettingsPath(): string { return path.join(app.getPath('userData'), 'settings.json'); } +/** + * Migrates legacy whisper hotkey keys into the current schema. + */ +function migrateLegacyWhisperHotkeys(rawHotkeys: Record): Record { + const hotkeys = { ...rawHotkeys } as Record; + if (!hotkeys['system-supercmd-whisper-speak-toggle']) { + if (hotkeys['system-supercmd-whisper-start']) { + hotkeys['system-supercmd-whisper-speak-toggle'] = hotkeys['system-supercmd-whisper-start']; + } else if (hotkeys['system-supercmd-whisper-stop']) { + hotkeys['system-supercmd-whisper-speak-toggle'] = hotkeys['system-supercmd-whisper-stop']; + } + } + if (hotkeys['system-supercmd-whisper-toggle']) { + if (!hotkeys['system-supercmd-whisper-start']) { + hotkeys['system-supercmd-whisper-start'] = hotkeys['system-supercmd-whisper-toggle']; + } + if (!hotkeys['system-supercmd-whisper']) { + hotkeys['system-supercmd-whisper'] = hotkeys['system-supercmd-whisper-toggle']; + } + } + delete hotkeys['system-supercmd-whisper-toggle']; + delete hotkeys['system-supercmd-whisper-start']; + delete hotkeys['system-supercmd-whisper-stop']; + + return Object.entries(hotkeys).reduce((acc, [commandId, shortcut]) => { + const normalizedCommandId = String(commandId || '').trim(); + const normalizedShortcut = String(shortcut || '').trim(); + if (!normalizedCommandId || !normalizedShortcut) return acc; + acc[normalizedCommandId] = normalizedShortcut; + return acc; + }, {} as Record); +} + +/** + * Removes blank alias entries so command alias resolution stays deterministic. + */ +function normalizeCommandAliases(rawAliases: Record): Record { + return Object.entries(rawAliases || {}).reduce((acc, [commandId, aliasValue]) => { + const normalizedCommandId = String(commandId || '').trim(); + const normalizedAlias = String(aliasValue || '').trim(); + if (!normalizedCommandId || !normalizedAlias) return acc; + acc[normalizedCommandId] = normalizedAlias; + return acc; + }, {} as Record); +} + +/** + * Ensures extension folder list is a clean string array. + */ +function sanitizeCustomExtensionFolders(rawFolders: unknown): string[] { + if (!Array.isArray(rawFolders)) return DEFAULT_SETTINGS.customExtensionFolders; + return rawFolders + .map((value: any) => String(value || '').trim()) + .filter(Boolean); +} + +/** + * Builds normalized settings from untrusted parsed JSON input. + */ +function buildSettingsFromParsed(parsed: any): AppSettings { + const parsedHotkeys = migrateLegacyWhisperHotkeys(parsed.commandHotkeys || {}); + const normalizedAliases = normalizeCommandAliases(parsed.commandAliases || {}); + + return { + globalShortcut: parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut, + openAtLogin: parsed.openAtLogin ?? DEFAULT_SETTINGS.openAtLogin, + disabledCommands: parsed.disabledCommands ?? DEFAULT_SETTINGS.disabledCommands, + enabledCommands: parsed.enabledCommands ?? DEFAULT_SETTINGS.enabledCommands, + customExtensionFolders: sanitizeCustomExtensionFolders(parsed.customExtensionFolders), + commandHotkeys: { + ...DEFAULT_SETTINGS.commandHotkeys, + ...parsedHotkeys, + }, + commandAliases: { + ...DEFAULT_SETTINGS.commandAliases, + ...normalizedAliases, + }, + pinnedCommands: parsed.pinnedCommands ?? DEFAULT_SETTINGS.pinnedCommands, + recentCommands: parsed.recentCommands ?? DEFAULT_SETTINGS.recentCommands, + // Existing users with older settings should not be forced into onboarding. + hasSeenOnboarding: parsed.hasSeenOnboarding ?? true, + hasSeenWhisperOnboarding: parsed.hasSeenWhisperOnboarding ?? false, + ai: { ...DEFAULT_AI_SETTINGS, ...parsed.ai }, + commandMetadata: parsed.commandMetadata ?? {}, + debugMode: parsed.debugMode ?? DEFAULT_SETTINGS.debugMode, + fontSize: normalizeFontSize(parsed.fontSize), + baseColor: normalizeBaseColor(parsed.baseColor), + appUpdaterLastCheckedAt: Number.isFinite(Number(parsed.appUpdaterLastCheckedAt)) + ? Math.max(0, Number(parsed.appUpdaterLastCheckedAt)) + : DEFAULT_SETTINGS.appUpdaterLastCheckedAt, + hyperKeySource: normalizeHyperKeySource(parsed.hyperKeySource), + hyperKeyIncludeShift: parsed.hyperKeyIncludeShift ?? DEFAULT_SETTINGS.hyperKeyIncludeShift, + hyperKeyQuickPressAction: normalizeHyperKeyQuickPressAction(parsed.hyperKeyQuickPressAction), + hyperReplaceModifierGlyphsWithHyper: + parsed.hyperReplaceModifierGlyphsWithHyper ?? DEFAULT_SETTINGS.hyperReplaceModifierGlyphsWithHyper, + }; +} + +/** + * Returns normalized settings with in-memory caching. + */ export function loadSettings(): AppSettings { if (settingsCache) return { ...settingsCache }; try { const raw = fs.readFileSync(getSettingsPath(), 'utf-8'); const parsed = JSON.parse(raw); - const parsedHotkeys = { ...(parsed.commandHotkeys || {}) }; - const parsedAliases = { ...(parsed.commandAliases || {}) } as Record; - if (!parsedHotkeys['system-supercmd-whisper-speak-toggle']) { - if (parsedHotkeys['system-supercmd-whisper-start']) { - parsedHotkeys['system-supercmd-whisper-speak-toggle'] = parsedHotkeys['system-supercmd-whisper-start']; - } else if (parsedHotkeys['system-supercmd-whisper-stop']) { - parsedHotkeys['system-supercmd-whisper-speak-toggle'] = parsedHotkeys['system-supercmd-whisper-stop']; - } - } - if (parsedHotkeys['system-supercmd-whisper-toggle']) { - if (!parsedHotkeys['system-supercmd-whisper-start']) { - parsedHotkeys['system-supercmd-whisper-start'] = parsedHotkeys['system-supercmd-whisper-toggle']; - } - if (!parsedHotkeys['system-supercmd-whisper']) { - parsedHotkeys['system-supercmd-whisper'] = parsedHotkeys['system-supercmd-whisper-toggle']; - } - } - delete parsedHotkeys['system-supercmd-whisper-toggle']; - delete parsedHotkeys['system-supercmd-whisper-start']; - delete parsedHotkeys['system-supercmd-whisper-stop']; - const normalizedAliases: Record = {}; - for (const [commandId, aliasValue] of Object.entries(parsedAliases)) { - const normalizedCommandId = String(commandId || '').trim(); - const normalizedAlias = String(aliasValue || '').trim(); - if (!normalizedCommandId || !normalizedAlias) continue; - normalizedAliases[normalizedCommandId] = normalizedAlias; - } - settingsCache = { - globalShortcut: parsed.globalShortcut ?? DEFAULT_SETTINGS.globalShortcut, - openAtLogin: parsed.openAtLogin ?? DEFAULT_SETTINGS.openAtLogin, - disabledCommands: parsed.disabledCommands ?? DEFAULT_SETTINGS.disabledCommands, - enabledCommands: parsed.enabledCommands ?? DEFAULT_SETTINGS.enabledCommands, - customExtensionFolders: Array.isArray(parsed.customExtensionFolders) - ? parsed.customExtensionFolders - .map((value: any) => String(value || '').trim()) - .filter(Boolean) - : DEFAULT_SETTINGS.customExtensionFolders, - commandHotkeys: { - ...DEFAULT_SETTINGS.commandHotkeys, - ...parsedHotkeys, - }, - commandAliases: { - ...DEFAULT_SETTINGS.commandAliases, - ...normalizedAliases, - }, - pinnedCommands: parsed.pinnedCommands ?? DEFAULT_SETTINGS.pinnedCommands, - recentCommands: parsed.recentCommands ?? DEFAULT_SETTINGS.recentCommands, - // Existing users with older settings should not be forced into onboarding. - hasSeenOnboarding: - parsed.hasSeenOnboarding ?? true, - hasSeenWhisperOnboarding: - parsed.hasSeenWhisperOnboarding ?? false, - ai: { ...DEFAULT_AI_SETTINGS, ...parsed.ai }, - commandMetadata: parsed.commandMetadata ?? {}, - debugMode: parsed.debugMode ?? DEFAULT_SETTINGS.debugMode, - fontSize: normalizeFontSize(parsed.fontSize), - baseColor: normalizeBaseColor(parsed.baseColor), - appUpdaterLastCheckedAt: Number.isFinite(Number(parsed.appUpdaterLastCheckedAt)) - ? Math.max(0, Number(parsed.appUpdaterLastCheckedAt)) - : DEFAULT_SETTINGS.appUpdaterLastCheckedAt, - hyperKeySource: normalizeHyperKeySource(parsed.hyperKeySource), - hyperKeyIncludeShift: parsed.hyperKeyIncludeShift ?? DEFAULT_SETTINGS.hyperKeyIncludeShift, - hyperKeyQuickPressAction: normalizeHyperKeyQuickPressAction(parsed.hyperKeyQuickPressAction), - hyperReplaceModifierGlyphsWithHyper: - parsed.hyperReplaceModifierGlyphsWithHyper ?? DEFAULT_SETTINGS.hyperReplaceModifierGlyphsWithHyper, - }; + settingsCache = buildSettingsFromParsed(parsed); } catch { settingsCache = { ...DEFAULT_SETTINGS }; } @@ -283,6 +347,9 @@ export function loadSettings(): AppSettings { return { ...settingsCache }; } +/** + * Persists a partial settings patch and updates in-memory cache. + */ export function saveSettings(patch: Partial): AppSettings { const current = loadSettings(); const updated = { ...current, ...patch }; @@ -297,6 +364,9 @@ export function saveSettings(patch: Partial): AppSettings { return { ...updated }; } +/** + * Resets cache so subsequent reads are reloaded from disk. + */ export function resetSettingsCache(): void { settingsCache = null; } @@ -319,6 +389,9 @@ function getOAuthTokensPath(): string { return path.join(app.getPath('userData'), 'oauth-tokens.json'); } +/** + * Reads OAuth tokens from disk once and memoizes them in-memory. + */ function loadOAuthTokens(): Record { if (oauthTokensCache) return oauthTokensCache; try { @@ -330,6 +403,9 @@ function loadOAuthTokens(): Record { return oauthTokensCache!; } +/** + * Writes OAuth tokens to disk and keeps cache in sync. + */ function saveOAuthTokens(tokens: Record): void { oauthTokensCache = tokens; try { @@ -339,17 +415,26 @@ function saveOAuthTokens(tokens: Record): void { } } +/** + * Stores/replaces token entry for a provider. + */ export function setOAuthToken(provider: string, token: OAuthTokenEntry): void { const tokens = loadOAuthTokens(); tokens[provider] = token; saveOAuthTokens(tokens); } +/** + * Reads token entry for a provider, if present. + */ export function getOAuthToken(provider: string): OAuthTokenEntry | null { const tokens = loadOAuthTokens(); return tokens[provider] || null; } +/** + * Deletes token entry for a provider. + */ export function removeOAuthToken(provider: string): void { const tokens = loadOAuthTokens(); delete tokens[provider]; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 48af6b3d..cff6fa49 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,8 +1,15 @@ /** * Launcher App * - * Dynamically displays all applications and System Settings. - * Shows category labels like Raycast. + * What this file is: + * - The main renderer surface that orchestrates launcher/search modes. + * + * What it does: + * - Coordinates command discovery, mode switching, overlays, and command execution. + * - Hosts system surfaces (AI, snippets, clipboard, file search, onboarding, extension views). + * + * Why we need it: + * - Centralizes command UX flow so global shortcuts and launcher interactions stay consistent. */ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; @@ -50,6 +57,300 @@ import AiChatView from './views/AiChatView'; import CursorPromptView from './views/CursorPromptView'; const STALE_OVERLAY_RESET_MS = 60_000; +const DEFAULT_LAUNCHER_SHORTCUT = 'Alt+Space'; +const DEFAULT_EDGE_TTS_VOICE = 'en-US-EricNeural'; +const DEFAULT_TTS_MODEL = 'edge-tts'; +const DEFAULT_BASE_COLOR = '#101113'; + +type GroupedCommands = { + contextual: CommandInfo[]; + pinned: CommandInfo[]; + recent: CommandInfo[]; + other: CommandInfo[]; +}; + +type CommandSectionMeta = { + title: string; + items: CommandInfo[]; + startIndex: number; +}; + +/** + * Removes empty alias keys/values to keep search matches deterministic. + */ +function normalizeCommandAliases(rawAliases: Record): Record { + return Object.entries(rawAliases || {}).reduce((acc, [commandId, alias]) => { + const normalizedCommandId = String(commandId || '').trim(); + const normalizedAlias = String(alias || '').trim(); + if (!normalizedCommandId || !normalizedAlias) return acc; + acc[normalizedCommandId] = normalizedAlias; + return acc; + }, {} as Record); +} + +/** + * Builds launcher sections (contextual/pinned/recent/other) from current filters and history. + */ +function buildGroupedCommands( + sourceCommands: CommandInfo[], + pinnedCommands: string[], + recentCommands: string[], + selectedTextSnapshot: string +): GroupedCommands { + const sourceMap = new Map(sourceCommands.map((cmd) => [cmd.id, cmd])); + const hasSelection = selectedTextSnapshot.trim().length > 0; + const contextual = hasSelection + ? (sourceMap.get('system-add-to-memory') ? [sourceMap.get('system-add-to-memory') as CommandInfo] : []) + : []; + const contextualIds = new Set(contextual.map((c) => c.id)); + + const pinned = pinnedCommands + .map((id) => sourceMap.get(id)) + .filter((cmd): cmd is CommandInfo => Boolean(cmd) && !contextualIds.has((cmd as CommandInfo).id)); + const pinnedSet = new Set(pinned.map((c) => c.id)); + + const recent = recentCommands + .map((id) => sourceMap.get(id)) + .filter( + (c): c is CommandInfo => + Boolean(c) && + !pinnedSet.has((c as CommandInfo).id) && + !contextualIds.has((c as CommandInfo).id) + ); + const recentSet = new Set(recent.map((c) => c.id)); + + const other = sourceCommands.filter( + (c) => !pinnedSet.has(c.id) && !recentSet.has(c.id) && !contextualIds.has(c.id) + ); + + return { contextual, pinned, recent, other }; +} + +type CommandListContentProps = { + listRef: React.RefObject; + itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; + isLoading: boolean; + displayCommands: CommandInfo[]; + calcResult: Awaited> | null; + selectedIndex: number; + commandSections: CommandSectionMeta[]; + commandAliases: Record; + searchQuery: string; + calcOffset: number; + onSetSelectedIndex: (index: number) => void; + onExecuteCommand: (command: CommandInfo) => void; + onHideWindow: () => void; + onOpenContextMenu: (x: number, y: number, commandId: string, selectedFlatIndex: number) => void; +}; + +/** + * Renders launcher list results, calculator card, and grouped command rows. + * + * Why: + * - Keeps the root App render focused on mode orchestration rather than list markup. + */ +const CommandListContent: React.FC = ({ + listRef, + itemRefs, + isLoading, + displayCommands, + calcResult, + selectedIndex, + commandSections, + commandAliases, + searchQuery, + calcOffset, + onSetSelectedIndex, + onExecuteCommand, + onHideWindow, + onOpenContextMenu, +}) => ( +
+ {isLoading ? ( +
+

Discovering apps...

+
+ ) : displayCommands.length === 0 && !calcResult ? ( +
+

No matching results

+
+ ) : ( +
+ {calcResult && ( +
(itemRefs.current[0] = el)} + className={`mx-1 mt-0.5 mb-2 px-6 py-4 rounded-xl cursor-pointer transition-colors border ${ + selectedIndex === 0 + ? 'bg-white/[0.08] border-white/[0.12]' + : 'bg-white/[0.03] border-white/[0.06] hover:bg-white/[0.05]' + }`} + onClick={() => { + navigator.clipboard.writeText(calcResult.result); + onHideWindow(); + }} + onMouseMove={() => onSetSelectedIndex(0)} + > +
+
+
{calcResult.input}
+
{calcResult.inputLabel}
+
+ +
+
{calcResult.result}
+
{calcResult.resultLabel}
+
+
+
+ )} + + {commandSections.map((section) => ( + +
+ {section.title} +
+ {section.items.map((command, i) => { + const flatIndex = section.startIndex + i; + const accessoryLabel = getCommandAccessoryLabel(command); + const fallbackCategory = getCategoryLabel(command.category); + const commandAlias = String(commandAliases[command.id] || '').trim(); + const aliasMatchesSearch = + Boolean(commandAlias) && + Boolean(searchQuery.trim()) && + commandAlias.toLowerCase().includes(searchQuery.trim().toLowerCase()); + + return ( +
(itemRefs.current[flatIndex + calcOffset] = el)} + className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ + flatIndex + calcOffset === selectedIndex ? 'selected' : '' + }`} + onClick={() => onExecuteCommand(command)} + onMouseMove={() => onSetSelectedIndex(flatIndex + calcOffset)} + onContextMenu={(e) => { + e.preventDefault(); + onOpenContextMenu(e.clientX, e.clientY, command.id, flatIndex + calcOffset); + }} + > +
+
+ {renderCommandIcon(command)} +
+
+
+ {getCommandDisplayTitle(command)} +
+ {accessoryLabel ? ( +
+ {accessoryLabel} +
+ ) : ( +
+ {fallbackCategory} +
+ )} + {aliasMatchesSearch ? ( +
+ {commandAlias} +
+ ) : null} +
+
+
+ ); + })} +
+ ))} +
+ )} +
+); + +type LauncherFooterProps = { + isLoading: boolean; + memoryActionLoading: boolean; + memoryFeedback: MemoryFeedback; + selectedCommand: CommandInfo | null; + displayCommandsCount: number; + primaryAction?: LauncherAction; + onOpenActions: () => void; +}; + +/** + * Renders bottom action/status bar for the launcher list mode. + */ +const LauncherFooter: React.FC = ({ + isLoading, + memoryActionLoading, + memoryFeedback, + selectedCommand, + displayCommandsCount, + primaryAction, + onOpenActions, +}) => { + if (isLoading) return null; + + return ( +
+
+ {memoryActionLoading ? ( + <> + + Adding to memory... + + ) : memoryFeedback ? ( + memoryFeedback.text + ) : selectedCommand ? ( + <> + + {renderCommandIcon(selectedCommand)} + + {getCommandDisplayTitle(selectedCommand)} + + ) : ( + `${displayCommandsCount} results` + )} +
+ {primaryAction && ( +
+ + {primaryAction.shortcut && ( + + {renderShortcutLabel(primaryAction.shortcut)} + + )} +
+ )} + +
+ ); +}; const App: React.FC = () => { const [commands, setCommands] = useState([]); @@ -90,7 +391,7 @@ const App: React.FC = () => { } = useSpeakManager({ showSpeak, setShowSpeak }); const [onboardingRequiresShortcutFix, setOnboardingRequiresShortcutFix] = useState(false); const [onboardingHotkeyPresses, setOnboardingHotkeyPresses] = useState(0); - const [launcherShortcut, setLauncherShortcut] = useState('Alt+Space'); + const [launcherShortcut, setLauncherShortcut] = useState(DEFAULT_LAUNCHER_SHORTCUT); const [showActions, setShowActions] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; @@ -120,10 +421,23 @@ const App: React.FC = () => { }); }, []); - const onExitAiMode = useCallback(() => { + /** + * Resets search selection state when returning to launcher list mode. + */ + const resetLauncherSelection = useCallback(() => { + setSearchQuery(''); + setSelectedIndex(0); + }, []); + + /** + * Focuses search input after a short delay for transitions. + */ + const focusSearchInputSoon = useCallback(() => { setTimeout(() => inputRef.current?.focus(), 50); }, []); + const onExitAiMode = focusSearchInputSoon; + const { aiResponse, aiStreaming, aiAvailable, aiQuery, setAiQuery, aiResponseRef, aiInputRef, setAiAvailable, @@ -199,22 +513,14 @@ const App: React.FC = () => { const shortcutStatus = await window.electron.getGlobalShortcutStatus(); setPinnedCommands(settings.pinnedCommands || []); setRecentCommands(settings.recentCommands || []); - setCommandAliases( - Object.entries(settings.commandAliases || {}).reduce((acc, [commandId, alias]) => { - const normalizedCommandId = String(commandId || '').trim(); - const normalizedAlias = String(alias || '').trim(); - if (!normalizedCommandId || !normalizedAlias) return acc; - acc[normalizedCommandId] = normalizedAlias; - return acc; - }, {} as Record) - ); - setLauncherShortcut(settings.globalShortcut || 'Alt+Space'); + setCommandAliases(normalizeCommandAliases(settings.commandAliases || {})); + setLauncherShortcut(settings.globalShortcut || DEFAULT_LAUNCHER_SHORTCUT); const speakToggleHotkey = settings.commandHotkeys?.['system-supercmd-whisper-speak-toggle'] || 'Fn'; setWhisperSpeakToggleLabel(formatShortcutLabel(speakToggleHotkey)); - setConfiguredEdgeTtsVoice(String(settings.ai?.edgeTtsVoice || 'en-US-EricNeural')); - setConfiguredTtsModel(String(settings.ai?.textToSpeechModel || 'edge-tts')); + setConfiguredEdgeTtsVoice(String(settings.ai?.edgeTtsVoice || DEFAULT_EDGE_TTS_VOICE)); + setConfiguredTtsModel(String(settings.ai?.textToSpeechModel || DEFAULT_TTS_MODEL)); applyAppFontSize(settings.fontSize); - applyBaseColor(settings.baseColor || '#101113'); + applyBaseColor(settings.baseColor || DEFAULT_BASE_COLOR); const shouldShowOnboarding = !settings.hasSeenOnboarding; setShowOnboarding(shouldShowOnboarding); setOnboardingRequiresShortcutFix(shouldShowOnboarding && !shortcutStatus.ok); @@ -223,11 +529,11 @@ const App: React.FC = () => { setPinnedCommands([]); setRecentCommands([]); setCommandAliases({}); - setLauncherShortcut('Alt+Space'); - setConfiguredEdgeTtsVoice('en-US-EricNeural'); - setConfiguredTtsModel('edge-tts'); + setLauncherShortcut(DEFAULT_LAUNCHER_SHORTCUT); + setConfiguredEdgeTtsVoice(DEFAULT_EDGE_TTS_VOICE); + setConfiguredTtsModel(DEFAULT_TTS_MODEL); applyAppFontSize(getDefaultAppFontSize()); - applyBaseColor('#101113'); + applyBaseColor(DEFAULT_BASE_COLOR); setShowOnboarding(false); setOnboardingRequiresShortcutFix(false); } @@ -432,8 +738,8 @@ const App: React.FC = () => { useEffect(() => { const cleanup = window.electron.onSettingsUpdated?.((settings: AppSettings) => { applyAppFontSize(settings.fontSize); - applyBaseColor(settings.baseColor || '#101113'); - setLauncherShortcut(settings.globalShortcut || 'Alt+Space'); + applyBaseColor(settings.baseColor || DEFAULT_BASE_COLOR); + setLauncherShortcut(settings.globalShortcut || DEFAULT_LAUNCHER_SHORTCUT); }); return cleanup; }, []); @@ -785,35 +1091,33 @@ const App: React.FC = () => { const sourceCommands = calcResult && filteredCommands.length === 0 ? contextualCommands : filteredCommands; - const groupedCommands = useMemo(() => { - const sourceMap = new Map(sourceCommands.map((cmd) => [cmd.id, cmd])); - const hasSelection = selectedTextSnapshot.trim().length > 0; - const contextual = hasSelection - ? (sourceMap.get('system-add-to-memory') ? [sourceMap.get('system-add-to-memory') as CommandInfo] : []) - : []; - const contextualIds = new Set(contextual.map((c) => c.id)); - - const pinned = pinnedCommands - .map((id) => sourceMap.get(id)) - .filter((cmd): cmd is CommandInfo => Boolean(cmd) && !contextualIds.has((cmd as CommandInfo).id)); - const pinnedSet = new Set(pinned.map((c) => c.id)); - - const recent = recentCommands - .map((id) => sourceMap.get(id)) - .filter( - (c): c is CommandInfo => - Boolean(c) && - !pinnedSet.has((c as CommandInfo).id) && - !contextualIds.has((c as CommandInfo).id) - ); - const recentSet = new Set(recent.map((c) => c.id)); - - const other = sourceCommands.filter( - (c) => !pinnedSet.has(c.id) && !recentSet.has(c.id) && !contextualIds.has(c.id) - ); + const groupedCommands = useMemo( + () => buildGroupedCommands(sourceCommands, pinnedCommands, recentCommands, selectedTextSnapshot), + [sourceCommands, pinnedCommands, recentCommands, selectedTextSnapshot] + ); - return { contextual, pinned, recent, other }; - }, [sourceCommands, pinnedCommands, recentCommands, selectedTextSnapshot]); + /** + * Precomputes section order and flat-list start indexes used by keyboard navigation. + */ + const commandSections = useMemo(() => { + const baseSections = [ + { title: 'Selected Text', items: groupedCommands.contextual }, + { title: 'Pinned', items: groupedCommands.pinned }, + { title: 'Recent', items: groupedCommands.recent }, + { title: 'Other', items: groupedCommands.other }, + ].filter((section) => section.items.length > 0); + + let runningIndex = 0; + return baseSections.map((section) => { + const resolved: CommandSectionMeta = { + title: section.title, + items: section.items, + startIndex: runningIndex, + }; + runningIndex += section.items.length; + return resolved; + }); + }, [groupedCommands]); const displayCommands = useMemo( () => [ @@ -1669,9 +1973,8 @@ const App: React.FC = () => { onClose={() => { setExtensionView(null); localStorage.removeItem(LAST_EXT_KEY); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} />
@@ -1690,9 +1993,8 @@ const App: React.FC = () => { { setShowClipboardManager(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} />
@@ -1732,9 +2034,8 @@ const App: React.FC = () => { initialView={showSnippetManager} onClose={() => { setShowSnippetManager(null); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} />
@@ -1753,9 +2054,8 @@ const App: React.FC = () => { { setShowFileSearch(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} />
@@ -1802,9 +2102,8 @@ const App: React.FC = () => { setShowOnboarding(false); setShowWhisperOnboarding(false); setOnboardingRequiresShortcutFix(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} /> @@ -1849,197 +2148,39 @@ const App: React.FC = () => { )}
- {/* Command list */} -
- {isLoading ? ( -
-

Discovering apps...

-
- ) : displayCommands.length === 0 && !calcResult ? ( -
-

No matching results

-
- ) : ( -
- {/* Calculator card */} - {calcResult && ( -
(itemRefs.current[0] = el)} - className={`mx-1 mt-0.5 mb-2 px-6 py-4 rounded-xl cursor-pointer transition-colors border ${ - selectedIndex === 0 - ? 'bg-white/[0.08] border-white/[0.12]' - : 'bg-white/[0.03] border-white/[0.06] hover:bg-white/[0.05]' - }`} - onClick={() => { - navigator.clipboard.writeText(calcResult.result); - window.electron.hideWindow(); - }} - onMouseMove={() => setSelectedIndex(0)} - > -
-
-
{calcResult.input}
-
{calcResult.inputLabel}
-
- -
-
{calcResult.result}
-
{calcResult.resultLabel}
-
-
-
- )} - - {[ - { title: 'Selected Text', items: groupedCommands.contextual }, - { title: 'Pinned', items: groupedCommands.pinned }, - { title: 'Recent', items: groupedCommands.recent }, - { title: 'Other', items: groupedCommands.other }, - ] - .filter((section) => section.items.length > 0) - .map((section) => section) - .reduce( - (acc, section) => { - const startIndex = acc.index; - acc.nodes.push( -
- {section.title} -
- ); - section.items.forEach((command, i) => { - const flatIndex = startIndex + i; - const accessoryLabel = getCommandAccessoryLabel(command); - const fallbackCategory = getCategoryLabel(command.category); - const commandAlias = String(commandAliases[command.id] || '').trim(); - const aliasMatchesSearch = - Boolean(commandAlias) && - Boolean(searchQuery.trim()) && - commandAlias.toLowerCase().includes(searchQuery.trim().toLowerCase()); - acc.nodes.push( -
(itemRefs.current[flatIndex + calcOffset] = el)} - className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ - flatIndex + calcOffset === selectedIndex ? 'selected' : '' - }`} - onClick={() => handleCommandExecute(command)} - onMouseMove={() => setSelectedIndex(flatIndex + calcOffset)} - onContextMenu={(e) => { - e.preventDefault(); - setSelectedIndex(flatIndex + calcOffset); - setShowActions(false); - setContextMenu({ - x: e.clientX, - y: e.clientY, - commandId: command.id, - }); - }} - > -
-
- {renderCommandIcon(command)} -
- -
-
- {getCommandDisplayTitle(command)} -
- {accessoryLabel ? ( -
- {accessoryLabel} -
- ) : ( -
- {fallbackCategory} -
- )} - {aliasMatchesSearch ? ( -
- {commandAlias} -
- ) : null} -
-
-
- ); - }); - acc.index += section.items.length; - return acc; - }, - { nodes: [] as React.ReactNode[], index: 0 } - ).nodes} -
- )} -
- - {/* Footer actions */} - {!isLoading && ( -
-
- {memoryActionLoading ? ( - <> - - Adding to memory... - - ) : memoryFeedback - ? memoryFeedback.text - : selectedCommand - ? ( - <> - - {renderCommandIcon(selectedCommand)} - - {getCommandDisplayTitle(selectedCommand)} - - ) - : `${displayCommands.length} results`} -
- {selectedActions[0] && ( -
- - {selectedActions[0].shortcut && ( - - {renderShortcutLabel(selectedActions[0].shortcut)} - - )} -
- )} - -
- )} + window.electron.hideWindow()} + onOpenContextMenu={(x, y, commandId, selectedFlatIndex) => { + setSelectedIndex(selectedFlatIndex); + setShowActions(false); + setContextMenu({ x, y, commandId }); + }} + /> + + { + setContextMenu(null); + setShowActions(true); + }} + />
{showActions && selectedActions.length > 0 && ( diff --git a/src/renderer/src/settings/ExtensionsTab.tsx b/src/renderer/src/settings/ExtensionsTab.tsx index 4966fd78..d64f24f2 100644 --- a/src/renderer/src/settings/ExtensionsTab.tsx +++ b/src/renderer/src/settings/ExtensionsTab.tsx @@ -1,3 +1,17 @@ +/** + * Extensions Settings Tab + * + * What this file is: + * - The control center for extension-level command settings. + * + * What it does: + * - Loads command/extension schemas, lets users configure hotkeys and preferences, + * and handles extension install/uninstall related actions. + * + * Why we need it: + * - Extension commands are dynamic, so they need a dedicated configuration surface. + */ + import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, @@ -35,14 +49,23 @@ type SettingsFocusTarget = { extensionName?: string; commandName?: string }; const EXT_PREFS_KEY_PREFIX = 'sc-ext-prefs:'; const CMD_PREFS_KEY_PREFIX = 'sc-ext-cmd-prefs:'; +/** + * Builds localStorage key for extension-scoped preferences. + */ function getExtPrefsKey(extName: string): string { return `${EXT_PREFS_KEY_PREFIX}${extName}`; } +/** + * Builds localStorage key for command-scoped preferences. + */ function getCmdPrefsKey(extName: string, cmdName: string): string { return `${CMD_PREFS_KEY_PREFIX}${extName}/${cmdName}`; } +/** + * Reads and validates JSON object storage payloads. + */ function readJsonObject(key: string): Record { try { const raw = localStorage.getItem(key); @@ -54,10 +77,16 @@ function readJsonObject(key: string): Record { } } +/** + * Persists JSON objects used by extension preference drafts. + */ function writeJsonObject(key: string, value: Record) { localStorage.setItem(key, JSON.stringify(value)); } +/** + * Resolves an initial preference value from schema defaults. + */ function getDefaultValue(pref: ExtensionPreferenceSchema): any { if (pref.default !== undefined) return pref.default; if (pref.type === 'checkbox') return false; @@ -65,6 +94,9 @@ function getDefaultValue(pref: ExtensionPreferenceSchema): any { return ''; } +/** + * Detects whether a required preference currently has no usable value. + */ function isPreferenceMissing(pref: ExtensionPreferenceSchema, value: any): boolean { if (!pref.required) return false; if (pref.type === 'checkbox') return value === undefined || value === null; @@ -72,6 +104,9 @@ function isPreferenceMissing(pref: ExtensionPreferenceSchema, value: any): boole return value === undefined || value === null; } +/** + * Creates comparable keys for resilient cross-source name matching. + */ const normalizeMatchKey = (value: string): string => value.trim().toLowerCase().replace(/[\s_]+/g, '-'); diff --git a/src/renderer/src/settings/GeneralTab.tsx b/src/renderer/src/settings/GeneralTab.tsx index 8a338f45..92cb66d2 100644 --- a/src/renderer/src/settings/GeneralTab.tsx +++ b/src/renderer/src/settings/GeneralTab.tsx @@ -1,16 +1,24 @@ /** * General Settings Tab * - * Structured row layout aligned with the settings design system. + * What this file is: + * - A focused settings surface for app-level controls users access frequently. + * + * What it does: + * - Manages launcher shortcut, UI font size, update actions, launch-at-login, and version info. + * + * Why we need it: + * - Keeps everyday settings in one place while separating advanced controls into dedicated tabs. */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { Keyboard, Info, RefreshCw, Download, RotateCcw, Type, Power } from 'lucide-react'; import HotkeyRecorder from './HotkeyRecorder'; import type { AppSettings, AppUpdaterStatus } from '../../types/electron'; import { applyAppFontSize, getDefaultAppFontSize } from '../utils/font-size'; type FontSizeOption = NonNullable; +type ShortcutStatus = 'idle' | 'success' | 'error'; const FONT_SIZE_OPTIONS: Array<{ id: FontSizeOption; label: string }> = [ { id: 'small', label: 'Small' }, @@ -18,6 +26,9 @@ const FONT_SIZE_OPTIONS: Array<{ id: FontSizeOption; label: string }> = [ { id: 'large', label: 'Large' }, ]; +/** + * Formats bytes into a readable unit for updater progress text. + */ function formatBytes(bytes?: number): string { const value = Number(bytes || 0); if (!Number.isFinite(value) || value <= 0) return '0 B'; @@ -28,6 +39,33 @@ function formatBytes(bytes?: number): string { return `${scaled.toFixed(precision)} ${units[exponent]}`; } +/** + * Maps updater state to a short human-readable summary. + */ +function getUpdaterPrimaryMessage(status: AppUpdaterStatus | null): string { + if (!status) return 'Check for and install packaged-app updates.'; + if (status.message) return status.message; + + switch (status.state) { + case 'unsupported': + return 'Updates are only available in packaged builds.'; + case 'checking': + return 'Checking for updates...'; + case 'available': + return `Update v${status.latestVersion || 'latest'} is available.`; + case 'not-available': + return 'You are already on the latest version.'; + case 'downloading': + return 'Downloading update...'; + case 'downloaded': + return 'Update downloaded. Restart to install.'; + case 'error': + return 'Could not complete the update action.'; + default: + return 'Check for and install packaged-app updates.'; + } +} + type SettingsRowProps = { icon: React.ReactNode; title: string; @@ -36,6 +74,12 @@ type SettingsRowProps = { children: React.ReactNode; }; +/** + * Shared row shell used across settings sections. + * + * Why: + * - Enforces consistent spacing/typography so each setting can stay lean. + */ const SettingsRow: React.FC = ({ icon, title, @@ -59,73 +103,94 @@ const SettingsRow: React.FC = ({
); -const GeneralTab: React.FC = () => { +/** + * Encapsulates async settings/update side effects so render JSX stays focused. + */ +function useGeneralTabController() { const [settings, setSettings] = useState(null); const [updaterStatus, setUpdaterStatus] = useState(null); const [updaterActionError, setUpdaterActionError] = useState(''); - const [shortcutStatus, setShortcutStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [shortcutStatus, setShortcutStatus] = useState('idle'); useEffect(() => { window.electron.getSettings().then((nextSettings) => { const normalizedFontSize = nextSettings.fontSize || getDefaultAppFontSize(); applyAppFontSize(normalizedFontSize); - setSettings({ - ...nextSettings, - fontSize: normalizedFontSize, - }); + setSettings({ ...nextSettings, fontSize: normalizedFontSize }); }); }, []); useEffect(() => { let disposed = false; - window.electron.appUpdaterGetStatus() + + window.electron + .appUpdaterGetStatus() .then((status) => { if (!disposed) setUpdaterStatus(status); }) .catch(() => {}); + const disposeUpdater = window.electron.onAppUpdaterStatus((status) => { if (!disposed) setUpdaterStatus(status); }); + return () => { disposed = true; disposeUpdater(); }; }, []); - const handleShortcutChange = async (newShortcut: string) => { + /** + * Updates launcher shortcut and provides immediate success/error feedback. + */ + const handleShortcutChange = useCallback(async (newShortcut: string) => { if (!newShortcut) return; setShortcutStatus('idle'); const success = await window.electron.updateGlobalShortcut(newShortcut); if (success) { - setSettings((prev) => - prev ? { ...prev, globalShortcut: newShortcut } : prev - ); + setSettings((prev) => (prev ? { ...prev, globalShortcut: newShortcut } : prev)); setShortcutStatus('success'); setTimeout(() => setShortcutStatus('idle'), 2000); - } else { - setShortcutStatus('error'); - setTimeout(() => setShortcutStatus('idle'), 3000); + return; } - }; - const handleFontSizeChange = async (nextFontSize: FontSizeOption) => { - if (!settings) return; - const previousFontSize = settings.fontSize || getDefaultAppFontSize(); - if (previousFontSize === nextFontSize) return; + setShortcutStatus('error'); + setTimeout(() => setShortcutStatus('idle'), 3000); + }, []); - setSettings((prev) => (prev ? { ...prev, fontSize: nextFontSize } : prev)); - applyAppFontSize(nextFontSize); + /** + * Applies font size optimistically, then persists it. + * Reverts UI if persistence fails. + */ + const handleFontSizeChange = useCallback( + async (nextFontSize: FontSizeOption) => { + if (!settings) return; + const previousFontSize = settings.fontSize || getDefaultAppFontSize(); + if (previousFontSize === nextFontSize) return; + + setSettings((prev) => (prev ? { ...prev, fontSize: nextFontSize } : prev)); + applyAppFontSize(nextFontSize); + + try { + await window.electron.saveSettings({ fontSize: nextFontSize }); + } catch { + setSettings((prev) => (prev ? { ...prev, fontSize: previousFontSize } : prev)); + applyAppFontSize(previousFontSize); + } + }, + [settings] + ); - try { - await window.electron.saveSettings({ fontSize: nextFontSize }); - } catch { - setSettings((prev) => (prev ? { ...prev, fontSize: previousFontSize } : prev)); - applyAppFontSize(previousFontSize); - } - }; + /** + * Toggles open-at-login and keeps UI state in sync with OS setting. + */ + const handleOpenAtLoginChange = useCallback(async (openAtLogin: boolean) => { + setSettings((prev) => (prev ? { ...prev, openAtLogin } : prev)); + await window.electron.setOpenAtLogin(openAtLogin); + }, []); - const handleCheckForUpdates = async () => { + const handleCheckForUpdates = useCallback(async () => { setUpdaterActionError(''); try { const status = await window.electron.appUpdaterCheckForUpdates(); @@ -133,9 +198,9 @@ const GeneralTab: React.FC = () => { } catch (error: any) { setUpdaterActionError(String(error?.message || error || 'Failed to check for updates.')); } - }; + }, []); - const handleDownloadUpdate = async () => { + const handleDownloadUpdate = useCallback(async () => { setUpdaterActionError(''); try { const status = await window.electron.appUpdaterDownloadUpdate(); @@ -143,9 +208,9 @@ const GeneralTab: React.FC = () => { } catch (error: any) { setUpdaterActionError(String(error?.message || error || 'Failed to download update.')); } - }; + }, []); - const handleRestartToInstall = async () => { + const handleRestartToInstall = useCallback(async () => { setUpdaterActionError(''); try { const ok = await window.electron.appUpdaterQuitAndInstall(); @@ -155,34 +220,47 @@ const GeneralTab: React.FC = () => { } catch (error: any) { setUpdaterActionError(String(error?.message || error || 'Failed to restart for update.')); } + }, []); + + return { + settings, + updaterStatus, + updaterActionError, + shortcutStatus, + handleShortcutChange, + handleFontSizeChange, + handleOpenAtLoginChange, + handleCheckForUpdates, + handleDownloadUpdate, + handleRestartToInstall, }; +} + +/** + * General settings screen. + * + * Why this component exists: + * - Keeps common global settings easy to discover and change. + */ +const GeneralTab: React.FC = () => { + const { + settings, + updaterStatus, + updaterActionError, + shortcutStatus, + handleShortcutChange, + handleFontSizeChange, + handleOpenAtLoginChange, + handleCheckForUpdates, + handleDownloadUpdate, + handleRestartToInstall, + } = useGeneralTabController(); const updaterProgress = Math.max(0, Math.min(100, Number(updaterStatus?.progressPercent || 0))); const updaterState = updaterStatus?.state || 'idle'; const updaterSupported = updaterStatus?.supported !== false; const currentVersion = updaterStatus?.currentVersion || '1.0.0'; - const updaterPrimaryMessage = useMemo(() => { - if (!updaterStatus) return 'Check for and install packaged-app updates.'; - if (updaterStatus.message) return updaterStatus.message; - switch (updaterStatus.state) { - case 'unsupported': - return 'Updates are only available in packaged builds.'; - case 'checking': - return 'Checking for updates...'; - case 'available': - return `Update v${updaterStatus.latestVersion || 'latest'} is available.`; - case 'not-available': - return 'You are already on the latest version.'; - case 'downloading': - return 'Downloading update...'; - case 'downloaded': - return 'Update downloaded. Restart to install.'; - case 'error': - return 'Could not complete the update action.'; - default: - return 'Check for and install packaged-app updates.'; - } - }, [updaterStatus]); + const updaterPrimaryMessage = useMemo(() => getUpdaterPrimaryMessage(updaterStatus), [updaterStatus]); if (!settings) { return
Loading settings...
; @@ -242,9 +320,7 @@ const GeneralTab: React.FC = () => { >
-

- {updaterPrimaryMessage} -

+

{updaterPrimaryMessage}

Current version: v{currentVersion} {updaterStatus?.latestVersion ? ` · Latest: v${updaterStatus.latestVersion}` : ''} @@ -260,7 +336,8 @@ const GeneralTab: React.FC = () => { />

- {updaterProgress.toFixed(0)}% · {formatBytes(updaterStatus?.transferredBytes)} / {formatBytes(updaterStatus?.totalBytes)} + {updaterProgress.toFixed(0)}% · {formatBytes(updaterStatus?.transferredBytes)} /{' '} + {formatBytes(updaterStatus?.totalBytes)}

)} @@ -314,10 +391,8 @@ const GeneralTab: React.FC = () => { { - const openAtLogin = e.target.checked; - setSettings((prev) => (prev ? { ...prev, openAtLogin } : prev)); - await window.electron.setOpenAtLogin(openAtLogin); + onChange={(e) => { + void handleOpenAtLoginChange(e.target.checked); }} className="w-4 h-4 rounded accent-cyan-400" /> @@ -331,9 +406,7 @@ const GeneralTab: React.FC = () => { description="Version information." withBorder={false} > -

- SuperCmd v{currentVersion} -

+

SuperCmd v{currentVersion}

From 0f8a5dc740f7eaf0742b148ee7ca906989a07f81 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 13:12:45 -0600 Subject: [PATCH 45/49] Windows fixes: snippet dialog flow and Ctrl-based shortcut UI/handlers --- src/main/main.ts | 10 +++-- src/renderer/src/ClipboardManager.tsx | 15 ++++--- src/renderer/src/FileSearchExtension.tsx | 29 +++++++++---- src/renderer/src/SnippetManager.tsx | 43 +++++++++++-------- .../src/components/ExtensionActionFooter.tsx | 5 ++- src/renderer/src/utils/hyper-key.ts | 11 +++-- 6 files changed, 70 insertions(+), 43 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 167a854c..ae1cd4ad 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4316,11 +4316,11 @@ async function runCommandById(commandId: string, source: 'launcher' | 'hotkey' = }); } if (commandId === 'system-import-snippets') { - await importSnippetsFromFile(mainWindow || undefined); + await importSnippetsFromFile(process.platform === 'win32' ? undefined : (mainWindow || undefined)); return true; } if (commandId === 'system-export-snippets') { - await exportSnippetsToFile(mainWindow || undefined); + await exportSnippetsToFile(process.platform === 'win32' ? undefined : (mainWindow || undefined)); return true; } if (commandId === 'system-create-script-command') { @@ -7951,7 +7951,8 @@ return appURL's |path|() as text`, ipcMain.handle('snippet-import', async (event: any) => { suppressBlurHide = true; try { - const result = await importSnippetsFromFile(getDialogParentWindow(event)); + const parent = process.platform === 'win32' ? undefined : getDialogParentWindow(event); + const result = await importSnippetsFromFile(parent); refreshSnippetExpander(); return result; } finally { @@ -7962,7 +7963,8 @@ return appURL's |path|() as text`, ipcMain.handle('snippet-export', async (event: any) => { suppressBlurHide = true; try { - return await exportSnippetsToFile(getDialogParentWindow(event)); + const parent = process.platform === 'win32' ? undefined : getDialogParentWindow(event); + return await exportSnippetsToFile(parent); } finally { suppressBlurHide = false; } diff --git a/src/renderer/src/ClipboardManager.tsx b/src/renderer/src/ClipboardManager.tsx index e9d99fb7..7f0ab14d 100644 --- a/src/renderer/src/ClipboardManager.tsx +++ b/src/renderer/src/ClipboardManager.tsx @@ -25,6 +25,9 @@ interface Action { } const ClipboardManager: React.FC = ({ onClose }) => { + const isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; + const primaryModifierPressed = (e: React.KeyboardEvent) => (isMac ? e.metaKey : e.ctrlKey); const [items, setItems] = useState([]); const [filteredItems, setFilteredItems] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -144,7 +147,7 @@ const ClipboardManager: React.FC = ({ onClose }) => { if (!itemToPaste) return; try { - // This copies to clipboard, hides window, and simulates Cmd+V + // This copies to clipboard, hides window, and simulates system paste. await window.electron.clipboardPasteItem(itemToPaste.id); } catch (e) { console.error('Failed to paste item:', e); @@ -197,7 +200,7 @@ const ClipboardManager: React.FC = ({ onClose }) => { { title: 'Copy to Clipboard', icon: , - shortcut: ['⌘', '↩'], + shortcut: [primaryModifierLabel, '↩'], execute: handleCopyToClipboard, }, { @@ -217,12 +220,12 @@ const ClipboardManager: React.FC = ({ onClose }) => { ]; const isMetaEnter = (e: React.KeyboardEvent) => - e.metaKey && + primaryModifierPressed(e) && (e.key === 'Enter' || e.key === 'Return' || e.code === 'Enter' || e.code === 'NumpadEnter'); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'k' && e.metaKey && !e.repeat) { + if (e.key === 'k' && primaryModifierPressed(e) && !e.repeat) { e.preventDefault(); setShowActions(p => !p); return; @@ -312,7 +315,7 @@ const ClipboardManager: React.FC = ({ onClose }) => { case 'Backspace': case 'Delete': - if (e.metaKey) { + if (primaryModifierPressed(e) || e.ctrlKey) { e.preventDefault(); if (filteredItems[selectedIndex]) { handleDeleteItem(); @@ -512,7 +515,7 @@ const ClipboardManager: React.FC = ({ onClose }) => { actionsButton={{ label: 'Actions', onClick: () => setShowActions(true), - shortcut: ['⌘', 'K'], + shortcut: [primaryModifierLabel, 'K'], }} /> diff --git a/src/renderer/src/FileSearchExtension.tsx b/src/renderer/src/FileSearchExtension.tsx index 37d1446a..62deae24 100644 --- a/src/renderer/src/FileSearchExtension.tsx +++ b/src/renderer/src/FileSearchExtension.tsx @@ -124,6 +124,9 @@ function matchesFileNameTerms(filePath: string, terms: string[]): boolean { } const FileSearchExtension: React.FC = ({ onClose }) => { + const isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; + const primaryModifierPressed = (e: React.KeyboardEvent) => (isMac ? e.metaKey : e.ctrlKey); const [query, setQuery] = useState(''); const [scopes, setScopes] = useState([]); const [scopeId, setScopeId] = useState('home'); @@ -317,7 +320,11 @@ const FileSearchExtension: React.FC = ({ onClose }) => if (!selectedPath || opening) return; setOpening(true); try { - await window.electron.execCommand('open', [selectedPath]); + if (window.electron.platform === 'win32') { + await window.electron.openUrl(selectedPath); + } else { + await window.electron.execCommand('open', [selectedPath]); + } await window.electron.hideWindow(); } catch (error) { console.error('Failed to open file:', error); @@ -329,7 +336,11 @@ const FileSearchExtension: React.FC = ({ onClose }) => const revealSelectedFile = useCallback(async () => { if (!selectedPath) return; try { - await window.electron.execCommand('open', ['-R', selectedPath]); + if (window.electron.platform === 'win32') { + await window.electron.execCommand('explorer.exe', ['/select,', selectedPath]); + } else { + await window.electron.execCommand('open', ['-R', selectedPath]); + } } catch (error) { console.error('Failed to reveal file:', error); } @@ -348,14 +359,14 @@ const FileSearchExtension: React.FC = ({ onClose }) => if (!selectedPath) return []; return [ { title: 'Open', shortcut: '↩', execute: openSelectedFile }, - { title: 'Reveal in Finder', shortcut: '⌘ ↩', execute: revealSelectedFile }, - { title: 'Copy Path', shortcut: '⌘ ⇧ C', execute: copySelectedPath }, + { title: isMac ? 'Reveal in Finder' : 'Reveal in Explorer', shortcut: `${primaryModifierLabel} ↩`, execute: revealSelectedFile }, + { title: 'Copy Path', shortcut: `${primaryModifierLabel} ⇧ C`, execute: copySelectedPath }, ]; - }, [selectedPath, openSelectedFile, revealSelectedFile, copySelectedPath]); + }, [selectedPath, openSelectedFile, revealSelectedFile, copySelectedPath, isMac, primaryModifierLabel]); const handleKeyDown = useCallback( async (e: React.KeyboardEvent) => { - if (e.key.toLowerCase() === 'k' && e.metaKey && !e.repeat) { + if (e.key.toLowerCase() === 'k' && primaryModifierPressed(e) && !e.repeat) { e.preventDefault(); setShowActions((prev) => !prev); return; @@ -399,14 +410,14 @@ const FileSearchExtension: React.FC = ({ onClose }) => } if (e.key === 'Enter') { e.preventDefault(); - if (e.metaKey) { + if (primaryModifierPressed(e)) { await revealSelectedFile(); return; } await openSelectedFile(); return; } - if (e.key.toLowerCase() === 'c' && e.metaKey && e.shiftKey) { + if (e.key.toLowerCase() === 'c' && primaryModifierPressed(e) && e.shiftKey) { e.preventDefault(); await copySelectedPath(); return; @@ -569,7 +580,7 @@ const FileSearchExtension: React.FC = ({ onClose }) => actionsButton={{ label: 'Actions', onClick: () => setShowActions((prev) => !prev), - shortcut: ['⌘', 'K'], + shortcut: [primaryModifierLabel, 'K'], }} /> diff --git a/src/renderer/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index b62f281f..47b08399 100644 --- a/src/renderer/src/SnippetManager.tsx +++ b/src/renderer/src/SnippetManager.tsx @@ -88,6 +88,8 @@ interface SnippetFormProps { } const SnippetForm: React.FC = ({ snippet, onSave, onCancel }) => { + const isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; const [name, setName] = useState(snippet?.name || ''); const [content, setContent] = useState(snippet?.content || ''); const [keyword, setKeyword] = useState(snippet?.keyword || ''); @@ -207,7 +209,7 @@ const SnippetForm: React.FC = ({ snippet, onSave, onCancel }) }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && e.metaKey) { + if (e.key === 'Enter' && (isMac ? e.metaKey : e.ctrlKey)) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { @@ -323,7 +325,7 @@ const SnippetForm: React.FC = ({ snippet, onSave, onCancel }) className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" > Save Snippet - + {primaryModifierLabel} @@ -388,6 +390,9 @@ const SnippetForm: React.FC = ({ snippet, onSave, onCancel }) // ─── Snippet Manager ───────────────────────────────────────────────── const SnippetManager: React.FC = ({ onClose, initialView }) => { + const isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; + const primaryModifierPressed = (e: React.KeyboardEvent) => (isMac ? e.metaKey : e.ctrlKey); const [view, setView] = useState<'search' | 'create' | 'edit'>(initialView); const [snippets, setSnippets] = useState([]); const [filteredSnippets, setFilteredSnippets] = useState([]); @@ -615,37 +620,37 @@ const SnippetManager: React.FC = ({ onClose, initialView }) { title: 'Copy to Clipboard', icon: , - shortcut: ['⌘', '↩'], + shortcut: [primaryModifierLabel, '↩'], execute: handleCopy, }, { title: 'Create Snippet', icon: , - shortcut: ['⌘', 'N'], + shortcut: [primaryModifierLabel, 'N'], execute: () => setView('create'), }, { title: activeSnippet?.pinned ? 'Unpin Snippet' : 'Pin Snippet', icon: activeSnippet?.pinned ? : , - shortcut: ['⇧', '⌘', 'P'], + shortcut: ['⇧', primaryModifierLabel, 'P'], execute: handleTogglePin, }, { title: 'Edit Snippet', icon: , - shortcut: ['⌘', 'E'], + shortcut: [primaryModifierLabel, 'E'], execute: handleEdit, }, { title: 'Duplicate Snippet', icon: , - shortcut: ['⌘', 'D'], + shortcut: [primaryModifierLabel, 'D'], execute: handleDuplicate, }, { title: 'Export Snippets', icon: , - shortcut: ['⇧', '⌘', 'S'], + shortcut: ['⇧', primaryModifierLabel, 'S'], execute: async () => { await window.electron.snippetExport(); }, @@ -653,7 +658,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) { title: 'Import Snippets', icon: , - shortcut: ['⇧', '⌘', 'I'], + shortcut: ['⇧', primaryModifierLabel, 'I'], execute: async () => { const result = await window.electron.snippetImport(); await loadSnippets(); @@ -680,14 +685,14 @@ const SnippetManager: React.FC = ({ onClose, initialView }) ]; const isMetaEnter = (e: React.KeyboardEvent) => - e.metaKey && + primaryModifierPressed(e) && (e.key === 'Enter' || e.key === 'Return' || e.code === 'Enter' || e.code === 'NumpadEnter'); // ─── Keyboard ─────────────────────────────────────────────────── const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'k' && e.metaKey && !e.repeat) { + if (e.key === 'k' && primaryModifierPressed(e) && !e.repeat) { e.preventDefault(); setShowActions((p) => !p); return; @@ -697,7 +702,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) if (e.key === 'Escape') { e.preventDefault(); setDynamicPrompt(null); - } else if (e.key === 'Enter' && e.metaKey) { + } else if (e.key === 'Enter' && primaryModifierPressed(e)) { e.preventDefault(); handleConfirmDynamicPrompt(); } @@ -747,32 +752,32 @@ const SnippetManager: React.FC = ({ onClose, initialView }) } } - if (e.key.toLowerCase() === 'e' && e.metaKey) { + if (e.key.toLowerCase() === 'e' && primaryModifierPressed(e)) { e.preventDefault(); handleEdit(); return; } - if (e.key.toLowerCase() === 'd' && e.metaKey) { + if (e.key.toLowerCase() === 'd' && primaryModifierPressed(e)) { e.preventDefault(); handleDuplicate(); return; } - if (e.key.toLowerCase() === 'p' && e.metaKey && e.shiftKey) { + if (e.key.toLowerCase() === 'p' && primaryModifierPressed(e) && e.shiftKey) { e.preventDefault(); handleTogglePin(); return; } - if (e.key.toLowerCase() === 'n' && e.metaKey) { + if (e.key.toLowerCase() === 'n' && primaryModifierPressed(e)) { e.preventDefault(); setView('create'); return; } - if (e.key.toLowerCase() === 's' && e.metaKey && e.shiftKey) { + if (e.key.toLowerCase() === 's' && primaryModifierPressed(e) && e.shiftKey) { e.preventDefault(); window.electron.snippetExport(); return; } - if (e.key.toLowerCase() === 'i' && e.metaKey && e.shiftKey) { + if (e.key.toLowerCase() === 'i' && primaryModifierPressed(e) && e.shiftKey) { e.preventDefault(); window.electron.snippetImport().then((result) => { loadSnippets(); @@ -1032,7 +1037,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) actionsButton={{ label: 'Actions', onClick: () => setShowActions(true), - shortcut: ['⌘', 'K'], + shortcut: [primaryModifierLabel, 'K'], }} /> diff --git a/src/renderer/src/components/ExtensionActionFooter.tsx b/src/renderer/src/components/ExtensionActionFooter.tsx index 08e6b28a..02d2feff 100644 --- a/src/renderer/src/components/ExtensionActionFooter.tsx +++ b/src/renderer/src/components/ExtensionActionFooter.tsx @@ -21,6 +21,9 @@ const ExtensionActionFooter: React.FC = ({ primaryAction, actionsButton, }) => { + const isMac = + typeof window !== 'undefined' && + (window as any)?.electron?.platform === 'darwin'; const primaryVisible = Boolean(primaryAction?.label); const showDivider = primaryVisible; @@ -58,7 +61,7 @@ const ExtensionActionFooter: React.FC = ({ className="flex items-center gap-1.5 text-white/50 hover:text-white/70 disabled:text-white/35 transition-colors" > {actionsButton.label} - {(actionsButton.shortcut || ['⌘', 'K']).map((key) => ( + {(actionsButton.shortcut || [isMac ? '⌘' : 'Ctrl', 'K']).map((key) => ( {key} diff --git a/src/renderer/src/utils/hyper-key.ts b/src/renderer/src/utils/hyper-key.ts index a832137a..1b4b3c34 100644 --- a/src/renderer/src/utils/hyper-key.ts +++ b/src/renderer/src/utils/hyper-key.ts @@ -8,6 +8,9 @@ export function collapseHyperShortcut(shortcut: string): string { } export function formatShortcutForDisplay(shortcut: string): string { + const isMac = + typeof window !== 'undefined' && + (window as any)?.electron?.platform === 'darwin'; const collapsed = collapseHyperShortcut(shortcut); return collapsed .split('+') @@ -15,10 +18,10 @@ export function formatShortcutForDisplay(shortcut: string): string { const value = String(token || '').trim(); if (!value) return value; if (/^hyper$/i.test(value) || value === '✦') return 'Hyper'; - if (/^(command|cmd)$/i.test(value)) return '⌘'; - if (/^(control|ctrl)$/i.test(value)) return '⌃'; - if (/^(alt|option)$/i.test(value)) return '⌥'; - if (/^shift$/i.test(value)) return '⇧'; + if (/^(command|cmd)$/i.test(value)) return isMac ? '⌘' : 'Ctrl'; + if (/^(control|ctrl)$/i.test(value)) return isMac ? '⌃' : 'Ctrl'; + if (/^(alt|option)$/i.test(value)) return isMac ? '⌥' : 'Alt'; + if (/^shift$/i.test(value)) return isMac ? '⇧' : 'Shift'; if (/^(function|fn)$/i.test(value)) return 'fn'; if (/^arrowup$/i.test(value)) return '↑'; if (/^arrowdown$/i.test(value)) return '↓'; From 00d846642867b82ba2de1960b6c1a654f5de648e Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 13:19:36 -0600 Subject: [PATCH 46/49] Fix Windows launcher latency and shortcut key rendering --- src/main/main.ts | 27 ++++++++++++++++-------- src/renderer/src/App.tsx | 32 +++++++++++++++++++++-------- src/renderer/src/utils/hyper-key.ts | 3 ++- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index ae1cd4ad..12457b37 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3355,7 +3355,7 @@ function setLauncherMode(mode: LauncherMode): void { function captureFrontmostAppContext(): void { if (process.platform === 'win32') { try { - const { execFileSync } = require('child_process'); + const { execFile } = require('child_process'); const psScript = [ '$sig = @"', 'using System;', @@ -3381,15 +3381,19 @@ function captureFrontmostAppContext(): void { ' } catch {}', '}', ].join('; '); - const output = String( - execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], { encoding: 'utf-8' }) || '' - ).trim(); - if (output) { - const [name, appPath] = output.split('|||'); - if (name && name !== 'SuperCmd' && name !== 'electron') { - lastFrontmostApp = { name, path: appPath || '' }; + execFile( + 'powershell', + ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], + { encoding: 'utf-8', windowsHide: true } as any, + (_err: Error | null, stdout?: string) => { + const output = String(stdout || '').trim(); + if (!output) return; + const [name, appPath] = output.split('|||'); + if (name && name !== 'SuperCmd' && name !== 'electron') { + lastFrontmostApp = { name, path: appPath || '' }; + } } - } + ); } catch { // keep previously captured value } @@ -5900,6 +5904,11 @@ app.whenReady().then(async () => { console.warn('[SnippetExpander] Failed to start:', e); } + // Warm command discovery in the background so first launcher open is fast. + void getAvailableCommands().catch((error) => { + console.warn('[Commands] Prewarm failed:', error); + }); + // Rebuilding all extensions on every startup can stall app launch if one // extension build hangs. Keep startup fast by default; allow opt-in. if (process.env.SUPERCMD_REBUILD_EXTENSIONS_ON_STARTUP === '1') { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cff6fa49..baef74d3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -277,6 +277,7 @@ type LauncherFooterProps = { selectedCommand: CommandInfo | null; displayCommandsCount: number; primaryAction?: LauncherAction; + primaryModifierLabel: string; onOpenActions: () => void; }; @@ -290,6 +291,7 @@ const LauncherFooter: React.FC = ({ selectedCommand, displayCommandsCount, primaryAction, + primaryModifierLabel, onOpenActions, }) => { if (isLoading) return null; @@ -345,7 +347,7 @@ const LauncherFooter: React.FC = ({ className="flex items-center gap-1.5 text-white/50 hover:text-white/70 transition-colors" > Actions - + {primaryModifierLabel} K @@ -353,6 +355,8 @@ const LauncherFooter: React.FC = ({ }; const App: React.FC = () => { + const isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; const [commands, setCommands] = useState([]); const [commandAliases, setCommandAliases] = useState>({}); const [pinnedCommands, setPinnedCommands] = useState([]); @@ -1017,11 +1021,19 @@ const App: React.FC = () => { !showOnboarding && !showWhisperOnboarding; + const isPrimaryModifierPressed = useCallback( + (event: Pick | Pick) => { + return isMac ? event.metaKey : event.ctrlKey; + }, + [isMac] + ); + useEffect(() => { if (!isLauncherModeActive) return; const onWindowKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; - if (!e.metaKey || String(e.key || '').toLowerCase() !== 'k' || e.repeat) return; + if (!isPrimaryModifierPressed(e) || String(e.key || '').toLowerCase() !== 'k' || e.repeat) return; + if (e.shiftKey || e.altKey) return; const target = e.target as HTMLElement | null; const active = document.activeElement as HTMLElement | null; @@ -1038,7 +1050,7 @@ const App: React.FC = () => { window.addEventListener('keydown', onWindowKeyDown, true); return () => window.removeEventListener('keydown', onWindowKeyDown, true); - }, [isLauncherModeActive]); + }, [isLauncherModeActive, isPrimaryModifierPressed]); useEffect(() => { return () => { @@ -1208,7 +1220,7 @@ const App: React.FC = () => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.metaKey && (e.key === 'k' || e.key === 'K') && !e.repeat) { + if (isPrimaryModifierPressed(e) && (e.key === 'k' || e.key === 'K') && !e.repeat && !e.shiftKey && !e.altKey) { e.preventDefault(); setShowActions((prev) => !prev); setContextMenu(null); @@ -1237,29 +1249,29 @@ const App: React.FC = () => { } return; } - if (e.metaKey && e.shiftKey && (e.key === 'P' || e.key === 'p')) { + if (isPrimaryModifierPressed(e) && e.shiftKey && !e.altKey && (e.key === 'P' || e.key === 'p')) { e.preventDefault(); togglePinSelectedCommand(); return; } - if (e.metaKey && e.shiftKey && (e.key === 'D' || e.key === 'd')) { + if (isPrimaryModifierPressed(e) && e.shiftKey && !e.altKey && (e.key === 'D' || e.key === 'd')) { e.preventDefault(); disableSelectedCommand(); return; } - if (e.metaKey && (e.key === 'Backspace' || e.key === 'Delete')) { + if (isPrimaryModifierPressed(e) && !e.shiftKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete')) { if (selectedCommand?.category === 'extension') { e.preventDefault(); uninstallSelectedExtension(); return; } } - if (e.metaKey && e.altKey && e.key === 'ArrowUp') { + if (isPrimaryModifierPressed(e) && e.altKey && !e.shiftKey && e.key === 'ArrowUp') { e.preventDefault(); moveSelectedPinnedCommand('up'); return; } - if (e.metaKey && e.altKey && e.key === 'ArrowDown') { + if (isPrimaryModifierPressed(e) && e.altKey && !e.shiftKey && e.key === 'ArrowDown') { e.preventDefault(); moveSelectedPinnedCommand('down'); return; @@ -1311,6 +1323,7 @@ const App: React.FC = () => { }, [ moveSelection, + isPrimaryModifierPressed, displayCommands, selectedIndex, searchQuery, @@ -2176,6 +2189,7 @@ const App: React.FC = () => { selectedCommand={selectedCommand} displayCommandsCount={displayCommands.length} primaryAction={selectedActions[0]} + primaryModifierLabel={primaryModifierLabel} onOpenActions={() => { setContextMenu(null); setShowActions(true); diff --git a/src/renderer/src/utils/hyper-key.ts b/src/renderer/src/utils/hyper-key.ts index 1b4b3c34..14f8092c 100644 --- a/src/renderer/src/utils/hyper-key.ts +++ b/src/renderer/src/utils/hyper-key.ts @@ -25,7 +25,8 @@ export function formatShortcutForDisplay(shortcut: string): string { if (/^(function|fn)$/i.test(value)) return 'fn'; if (/^arrowup$/i.test(value)) return '↑'; if (/^arrowdown$/i.test(value)) return '↓'; - if (/^(backspace|delete)$/i.test(value)) return '⌫'; + if (/^backspace$/i.test(value)) return isMac ? '⌫' : 'Backspace'; + if (/^delete$/i.test(value)) return isMac ? '⌦' : 'Del'; if (/^period$/i.test(value)) return '.'; return value.length === 1 ? value.toUpperCase() : value; }) From ea0736dddcd1198ad10b6014bae791cbf5c1efca Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 13:25:37 -0600 Subject: [PATCH 47/49] Fix Windows snippet import/export dialogs opening behind launcher --- src/main/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 12457b37..36929f77 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7960,7 +7960,7 @@ return appURL's |path|() as text`, ipcMain.handle('snippet-import', async (event: any) => { suppressBlurHide = true; try { - const parent = process.platform === 'win32' ? undefined : getDialogParentWindow(event); + const parent = getDialogParentWindow(event); const result = await importSnippetsFromFile(parent); refreshSnippetExpander(); return result; @@ -7972,7 +7972,7 @@ return appURL's |path|() as text`, ipcMain.handle('snippet-export', async (event: any) => { suppressBlurHide = true; try { - const parent = process.platform === 'win32' ? undefined : getDialogParentWindow(event); + const parent = getDialogParentWindow(event); return await exportSnippetsToFile(parent); } finally { suppressBlurHide = false; From 399949b466298ac5f9ac30ab8bb6ab0530f84eee Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 13:30:27 -0600 Subject: [PATCH 48/49] Add Windows regression suite for snippets shortcuts and calculator --- FEATURE_MATRIX.md | 17 +++ WINDOWS_REGRESSION_TEST_PLAN.md | 109 ++++++++++++++++++ package.json | 1 + src/main/__tests__/snippet-store.test.ts | 101 ++++++++++++++++ .../src/__tests__/shortcut-format.test.ts | 32 +++++ .../src/__tests__/smart-calculator.test.ts | 28 +++++ vitest.config.ts | 2 +- 7 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 WINDOWS_REGRESSION_TEST_PLAN.md create mode 100644 src/main/__tests__/snippet-store.test.ts create mode 100644 src/renderer/src/__tests__/shortcut-format.test.ts create mode 100644 src/renderer/src/__tests__/smart-calculator.test.ts diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 0c7c063e..62554e97 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -140,3 +140,20 @@ A Windows release is accepted only when all lines below are completed on at leas Current summary: - Core Windows paths have been implemented for shared text insertion and snippet keyword expansion. - Remaining work is runtime certification and any bugfixes found during that pass. + +--- + +## 10) Automated Regression Coverage (Now Enforced) + +Automated checks are now documented in `WINDOWS_REGRESSION_TEST_PLAN.md` and runnable via: + +- `npm test` +- `npm run test:windows-regression` + +Current automated guardrails: + +| Area | Test file | What it catches | +|---|---|---| +| Shortcut label platform parity | `src/renderer/src/__tests__/shortcut-format.test.ts` | Prevents regressions where Windows renders macOS key labels (`Cmd`, mac symbols) instead of `Ctrl`/`Del`/`Backspace`. | +| Calculator + unit conversion correctness | `src/renderer/src/__tests__/smart-calculator.test.ts` | Validates arithmetic, conversions, and non-calculation fallback behavior. | +| Snippet import/export pipeline | `src/main/__tests__/snippet-store.test.ts` | Validates export shape, Raycast-style imports (`text`), duplicate skipping, and keyword sanitization. | diff --git a/WINDOWS_REGRESSION_TEST_PLAN.md b/WINDOWS_REGRESSION_TEST_PLAN.md new file mode 100644 index 00000000..57b03802 --- /dev/null +++ b/WINDOWS_REGRESSION_TEST_PLAN.md @@ -0,0 +1,109 @@ +# Windows Regression Test Plan + +Branch scope: `feat/windows-foundation` + +## Goal + +Catch Windows regressions early for: +- Shortcut labels and key behavior (`Ctrl` vs `Cmd`). +- Snippet import/export/copy/paste flows. +- Clipboard/snippet text insertion pipelines. +- Calculator and unit conversion behavior. +- Launcher search, command execution, and system commands. + +## Test Layers + +1. Unit tests (fast, CI-safe) +- Validate pure logic and formatting behavior. +- Run on every commit. + +2. Integration smoke tests (app-level behavior without full desktop automation) +- Validate snippet persistence/import/export logic. +- Run on every PR. + +3. Windows runtime certification (manual, native desktop) +- Validate global hotkeys, focus transitions, foreground app paste, dialogs, native binaries. +- Required before release. + +## Automated Suite + +Run: + +```bash +npm test +npm run test:windows-regression +``` + +Coverage currently automated: +- `src/renderer/src/__tests__/shortcut-format.test.ts` + - Verifies `Cmd`-style accelerators render as `Ctrl`/`Del`/`Backspace` on Windows. + - Verifies macOS symbol rendering stays intact. +- `src/renderer/src/__tests__/smart-calculator.test.ts` + - Verifies arithmetic, unit conversion, temperature conversion, and non-math fallback. +- `src/main/__tests__/snippet-store.test.ts` + - Verifies snippet export JSON shape. + - Verifies Raycast-style import (`text` field) support. + - Verifies duplicate detection and keyword sanitization. + +## Manual Windows Certification + +Environment: +- Windows 11 (required), Windows 10 (recommended). +- One packaged build (`npm run package`) and one dev build. + +Runbook: + +1. Launcher and Search +- Open launcher from 3+ host apps (browser, terminal, editor). +- Verify close/open cycles are stable. +- Search by title, alias, pinned, recent. +- Confirm action footer shows `Ctrl` shortcuts (not `Cmd`). + +2. Snippets +- Create snippet with keyword and dynamic placeholder. +- Copy to clipboard flow. +- Paste into Notepad and VS Code. +- Export snippets and confirm file picker appears in front. +- Delete snippet, import exported file, verify imported/skipped counts. +- Re-import same file and confirm dedupe behavior. + +3. Clipboard History +- Copy 5+ entries from different apps. +- Search entries and paste selected item. +- Delete one item and clear all. +- Restart app and confirm persistence behavior. + +4. Text Insertion Pipelines +- `hideAndPaste` into browser input, Notepad, Office app. +- Verify punctuation, multiline content, and braces. +- Verify no focus-lock or missed paste after launcher hides. + +5. Calculator and Conversions +- Arithmetic (`2+2`, `144/12`). +- Unit (`10 cm to in`, `5 km to mi`). +- Temperature (`100 c to f`). +- Verify result copy behavior from launcher. + +6. App/System Commands +- Open common Win32 apps and UWP apps. +- Run all Windows settings commands used by SuperCmd (`ms-settings:` entries). +- Verify icons render or gracefully fall back. + +7. Extensions / AI / Whisper / Speak +- Install one extension, run command, uninstall. +- Validate AI chat request/stream/cancel. +- Validate whisper hotkey press/hold/release lifecycle. +- Validate speak start/stop and focus return. + +8. Packaging and Startup +- Install packaged app. +- Validate open-at-login. +- Validate updater status display path. + +## Release Gate + +A Windows release is allowed only if: +- All automated tests pass. +- No blocker failures in snippet/clipboard/paste pipelines. +- No blocker failures in launcher hotkeys/shortcuts/focus behavior. +- Manual Windows certification checklist is completed and recorded. diff --git a/package.json b/package.json index b2b98aea..56d4c153 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:renderer": "vite build", "build:native": "node scripts/build-native.js", "test": "vitest run", + "test:windows-regression": "vitest run src/main/__tests__/snippet-store.test.ts src/renderer/src/__tests__/shortcut-format.test.ts src/renderer/src/__tests__/smart-calculator.test.ts", "postinstall": "electron-builder install-app-deps", "start": "node scripts/launch-electron.js", "package": "npm run build && electron-builder" diff --git a/src/main/__tests__/snippet-store.test.ts b/src/main/__tests__/snippet-store.test.ts new file mode 100644 index 00000000..174cf9ed --- /dev/null +++ b/src/main/__tests__/snippet-store.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +let mockUserDataPath = ''; +const mockShowSaveDialog = vi.fn(); +const mockShowOpenDialog = vi.fn(); + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') return mockUserDataPath; + if (name === 'temp') return os.tmpdir(); + return mockUserDataPath; + }, + }, + clipboard: { + readText: () => '', + writeText: () => {}, + }, + dialog: { + showSaveDialog: (...args: any[]) => mockShowSaveDialog(...args), + showOpenDialog: (...args: any[]) => mockShowOpenDialog(...args), + }, + BrowserWindow: class BrowserWindow {}, +})); + +import { + createSnippet, + deleteAllSnippets, + exportSnippetsToFile, + getAllSnippets, + importSnippetsFromFile, + initSnippetStore, +} from '../snippet-store'; + +describe('snippet-store import/export', () => { + beforeEach(() => { + mockUserDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-snippet-test-')); + mockShowSaveDialog.mockReset(); + mockShowOpenDialog.mockReset(); + initSnippetStore(); + deleteAllSnippets(); + }); + + afterEach(() => { + deleteAllSnippets(); + try { + fs.rmSync(mockUserDataPath, { recursive: true, force: true }); + } catch {} + }); + + it('exports snippets to a JSON file', async () => { + createSnippet({ + name: 'Greeting', + content: 'Hello {argument name="Name"}', + keyword: 'greet', + }); + const outputPath = path.join(mockUserDataPath, 'exported-snippets.json'); + mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: outputPath }); + + const ok = await exportSnippetsToFile(); + expect(ok).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); + + const exported = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); + expect(exported.type).toBe('snippets'); + expect(Array.isArray(exported.snippets)).toBe(true); + expect(exported.snippets[0].name).toBe('Greeting'); + }); + + it('imports raycast-style snippets and skips duplicates', async () => { + const importPath = path.join(mockUserDataPath, 'raycast-snippets.json'); + fs.writeFileSync( + importPath, + JSON.stringify( + [ + { name: 'Meeting Link', text: 'https://meet.example.com', keyword: 'meet' }, + { name: 'Invalid Keyword', text: 'value', keyword: 'bad"quote' }, + ], + null, + 2 + ), + 'utf-8' + ); + mockShowOpenDialog.mockResolvedValue({ canceled: false, filePaths: [importPath] }); + + const first = await importSnippetsFromFile(); + expect(first.imported).toBe(2); + expect(first.skipped).toBe(0); + expect(getAllSnippets()).toHaveLength(2); + + const invalidKeywordSnippet = getAllSnippets().find((s) => s.name === 'Invalid Keyword'); + expect(invalidKeywordSnippet?.keyword).toBeUndefined(); + + const second = await importSnippetsFromFile(); + expect(second.imported).toBe(0); + expect(second.skipped).toBe(2); + }); +}); diff --git a/src/renderer/src/__tests__/shortcut-format.test.ts b/src/renderer/src/__tests__/shortcut-format.test.ts new file mode 100644 index 00000000..6ce8f7b2 --- /dev/null +++ b/src/renderer/src/__tests__/shortcut-format.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { formatShortcutForDisplay } from '../utils/hyper-key'; + +function setElectronPlatform(platform: 'darwin' | 'win32' | null): void { + if (platform === null) { + delete (globalThis as any).window; + return; + } + (globalThis as any).window = { + electron: { platform }, + }; +} + +describe('shortcut formatting', () => { + afterEach(() => { + setElectronPlatform(null); + }); + + it('formats command shortcuts as Ctrl on Windows', () => { + setElectronPlatform('win32'); + expect(formatShortcutForDisplay('Cmd+Shift+P')).toBe('Ctrl + Shift + P'); + expect(formatShortcutForDisplay('Cmd+Delete')).toBe('Ctrl + Del'); + expect(formatShortcutForDisplay('Ctrl+Backspace')).toBe('Ctrl + Backspace'); + }); + + it('formats command shortcuts as symbols on macOS', () => { + setElectronPlatform('darwin'); + expect(formatShortcutForDisplay('Cmd+Shift+P')).toBe('⌘ + ⇧ + P'); + expect(formatShortcutForDisplay('Cmd+Delete')).toBe('⌘ + ⌦'); + expect(formatShortcutForDisplay('Ctrl+Backspace')).toBe('⌃ + ⌫'); + }); +}); diff --git a/src/renderer/src/__tests__/smart-calculator.test.ts b/src/renderer/src/__tests__/smart-calculator.test.ts new file mode 100644 index 00000000..4fadfef8 --- /dev/null +++ b/src/renderer/src/__tests__/smart-calculator.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { tryCalculate } from '../smart-calculator'; + +describe('smart calculator', () => { + it('evaluates arithmetic expressions', () => { + const result = tryCalculate('2+2'); + expect(result).not.toBeNull(); + expect(result?.result).toBe('4'); + }); + + it('converts common length units', () => { + const result = tryCalculate('10 cm to in'); + expect(result).not.toBeNull(); + expect(result?.input).toContain('cm'); + expect(result?.result).toContain('in'); + }); + + it('converts temperature units', () => { + const result = tryCalculate('100 c to f'); + expect(result).not.toBeNull(); + expect(result?.result).toContain('°F'); + expect(result?.result.startsWith('212')).toBe(true); + }); + + it('returns null for non-calculation queries', () => { + expect(tryCalculate('open chrome')).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 16efc739..1d839cca 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/main/__tests__/**/*.test.ts'], + include: ['src/**/__tests__/**/*.test.ts'], }, }); From 38de08af40ee8a9adfe640645b1589754a0e1010 Mon Sep 17 00:00:00 2001 From: elicep01 Date: Sat, 21 Feb 2026 13:39:59 -0600 Subject: [PATCH 49/49] Add Playwright Windows launcher smoke test scaffolding --- .gitignore | 4 +- FEATURE_MATRIX.md | 2 + WINDOWS_REGRESSION_TEST_PLAN.md | 11 +++++ e2e/windows/launcher-shortcuts.spec.ts | 63 ++++++++++++++++++++++++++ package.json | 2 + playwright.e2e.config.ts | 20 ++++++++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 e2e/windows/launcher-shortcuts.spec.ts create mode 100644 playwright.e2e.config.ts diff --git a/.gitignore b/.gitignore index beeb1a55..5f51575d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ out/ .DS_Store *.log *.obj +playwright-report/ +test-results/ extensions* -extensions/* \ No newline at end of file +extensions/* diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 62554e97..b429a34f 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -149,6 +149,7 @@ Automated checks are now documented in `WINDOWS_REGRESSION_TEST_PLAN.md` and run - `npm test` - `npm run test:windows-regression` +- `npm run test:e2e:windows` Current automated guardrails: @@ -157,3 +158,4 @@ Current automated guardrails: | Shortcut label platform parity | `src/renderer/src/__tests__/shortcut-format.test.ts` | Prevents regressions where Windows renders macOS key labels (`Cmd`, mac symbols) instead of `Ctrl`/`Del`/`Backspace`. | | Calculator + unit conversion correctness | `src/renderer/src/__tests__/smart-calculator.test.ts` | Validates arithmetic, conversions, and non-calculation fallback behavior. | | Snippet import/export pipeline | `src/main/__tests__/snippet-store.test.ts` | Validates export shape, Raycast-style imports (`text`), duplicate skipping, and keyword sanitization. | +| Launcher Windows shortcut UI smoke | `e2e/windows/launcher-shortcuts.spec.ts` | Validates Electron launcher UI shows `Ctrl`-based actions and `Ctrl+K` overlay behavior on Windows. | diff --git a/WINDOWS_REGRESSION_TEST_PLAN.md b/WINDOWS_REGRESSION_TEST_PLAN.md index 57b03802..c28c25a3 100644 --- a/WINDOWS_REGRESSION_TEST_PLAN.md +++ b/WINDOWS_REGRESSION_TEST_PLAN.md @@ -32,6 +32,14 @@ Run: ```bash npm test npm run test:windows-regression +npm run test:e2e:windows +``` + +If Playwright is not installed yet in your environment: + +```bash +npm install -D @playwright/test playwright +npx playwright install ``` Coverage currently automated: @@ -44,6 +52,9 @@ Coverage currently automated: - Verifies snippet export JSON shape. - Verifies Raycast-style import (`text` field) support. - Verifies duplicate detection and keyword sanitization. +- `e2e/windows/launcher-shortcuts.spec.ts` + - Launches the Electron app and validates Windows `Ctrl` shortcut rendering in launcher/actions surfaces. + - Validates `Ctrl+K` opens the actions overlay and exposes core action rows. ## Manual Windows Certification diff --git a/e2e/windows/launcher-shortcuts.spec.ts b/e2e/windows/launcher-shortcuts.spec.ts new file mode 100644 index 00000000..7ad03fde --- /dev/null +++ b/e2e/windows/launcher-shortcuts.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +const appPath = process.cwd(); + +test.describe('Windows launcher smoke', () => { + test.skip(process.platform !== 'win32', 'Windows-only smoke suite'); + + test('shows Ctrl-based actions shortcut in launcher footer', async () => { + const electronApp = await electron.launch({ + args: [appPath], + env: { + ...process.env, + NODE_ENV: 'development', + }, + }); + + try { + const window = await electronApp.firstWindow(); + await window.waitForLoadState('domcontentloaded'); + + const onboardingVisible = await window.getByText('Get Started').count(); + test.skip(onboardingVisible > 0, 'Onboarding is active; run after onboarding completes once.'); + + const searchInput = window.locator('input[placeholder="Search apps and settings..."]'); + await expect(searchInput).toBeVisible(); + + await searchInput.click(); + await searchInput.press('Control+K'); + + await expect(window.getByText('Actions')).toBeVisible(); + await expect(window.locator('kbd', { hasText: 'Ctrl' }).first()).toBeVisible(); + await expect(window.locator('kbd', { hasText: '⌘' })).toHaveCount(0); + } finally { + await electronApp.close(); + } + }); + + test('opens actions overlay with Ctrl+K and shows Open Command row', async () => { + const electronApp = await electron.launch({ + args: [appPath], + env: { + ...process.env, + NODE_ENV: 'development', + }, + }); + + try { + const window = await electronApp.firstWindow(); + await window.waitForLoadState('domcontentloaded'); + + const onboardingVisible = await window.getByText('Get Started').count(); + test.skip(onboardingVisible > 0, 'Onboarding is active; run after onboarding completes once.'); + + const searchInput = window.locator('input[placeholder="Search apps and settings..."]'); + await expect(searchInput).toBeVisible(); + await searchInput.click(); + await searchInput.press('Control+K'); + + await expect(window.getByText('Open Command')).toBeVisible(); + } finally { + await electronApp.close(); + } + }); +}); diff --git a/package.json b/package.json index 56d4c153..3158e293 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "build:native": "node scripts/build-native.js", "test": "vitest run", "test:windows-regression": "vitest run src/main/__tests__/snippet-store.test.ts src/renderer/src/__tests__/shortcut-format.test.ts src/renderer/src/__tests__/smart-calculator.test.ts", + "test:e2e:windows": "npx playwright test -c playwright.e2e.config.ts --project=windows-launcher", + "test:windows:full": "npm run test:windows-regression && npm run test:e2e:windows", "postinstall": "electron-builder install-app-deps", "start": "node scripts/launch-electron.js", "package": "npm run build && electron-builder" diff --git a/playwright.e2e.config.ts b/playwright.e2e.config.ts new file mode 100644 index 00000000..6ebbdd88 --- /dev/null +++ b/playwright.e2e.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e/windows', + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + reporter: [['list']], + use: { + actionTimeout: 10_000, + }, + projects: [ + { + name: 'windows-launcher', + testMatch: /.*\.spec\.ts/, + }, + ], +});