diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31f36ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +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 here. + # build:native now compiles hotkey-hold-monitor.exe via gcc (MinGW). + - 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. + # 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 + + - 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/.gitignore b/.gitignore index cea61e9..5f51575 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ dist/ 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 new file mode 100644 index 0000000..b429a34 --- /dev/null +++ b/FEATURE_MATRIX.md @@ -0,0 +1,161 @@ +# SuperCmd Windows Feature Certification Matrix + +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. + +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) 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) Discovery and Indexing (Windows Native) + +| 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. | + +--- + +## 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. | + +--- + +## 4) Clipboard, Snippets, and Text Insertion Paths + +These are critical because many features depend on shared text insertion behavior. + +| 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. | + +--- + +## 5) AI, Memory, Whisper, and Speak + +| 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. | + +--- + +## 6) Extensions and Raycast Compatibility + +| 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. | + +--- + +## 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 | +|---|---|---|---| +| 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 | + +--- + +## 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. + +--- + +## 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` +- `npm run test:e2e:windows` + +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. | +| 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 new file mode 100644 index 0000000..c28c25a --- /dev/null +++ b/WINDOWS_REGRESSION_TEST_PLAN.md @@ -0,0 +1,120 @@ +# 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 +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: +- `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. +- `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 + +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/e2e/windows/launcher-shortcuts.spec.ts b/e2e/windows/launcher-shortcuts.spec.ts new file mode 100644 index 0000000..7ad03fd --- /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-lock.json b/package-lock.json index 892d262..25c291b 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 80a7dbf..3158e29 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,17 @@ "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": "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", + "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": "electron .", + "start": "node scripts/launch-electron.js", "package": "npm run build && electron-builder" }, "license": "ISC", @@ -28,12 +32,13 @@ "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": { @@ -96,6 +101,24 @@ "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/playwright.e2e.config.ts b/playwright.e2e.config.ts new file mode 100644 index 0000000..6ebbdd8 --- /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/, + }, + ], +}); diff --git a/scripts/build-native.js b/scripts/build-native.js new file mode 100644 index 0000000..12638f8 --- /dev/null +++ b/scripts/build-native.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/** + * scripts/build-native.js + * + * 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'); + +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); +} + +// ── Windows ──────────────────────────────────────────────────────────────── + +if (process.platform === 'win32') { + // Probe for a C compiler. Try gcc/clang on PATH first, then search for + // cl.exe in standard Visual Studio install locations (so this works without + // needing a Developer Command Prompt or VS on PATH). + function findCCompiler() { + const { execSync: probe } = require('child_process'); + + // Build a self-contained cl.exe compiler entry with explicit include/lib paths. + function makeMsvcCompiler(clPath) { + const msvcBin = path.dirname(clPath); // .../MSVC//bin/Hostx64/x64 + // Go up 3 levels: x64 → Hostx64 → bin → + const msvcRoot = path.resolve(msvcBin, '..', '..', '..'); + const msvcInc = path.join(msvcRoot, 'include'); + const msvcLib = path.join(msvcRoot, 'lib', 'x64'); + const kitsBase = 'C:\\Program Files (x86)\\Windows Kits\\10'; + let sdkVer = ''; + try { + const sdks = fs.readdirSync(path.join(kitsBase, 'Include')).filter(Boolean).sort(); + sdkVer = sdks[sdks.length - 1] || ''; + } catch {} + const incs = [msvcInc]; + const libs = [msvcLib]; + if (sdkVer) { + incs.push( + path.join(kitsBase, 'Include', sdkVer, 'ucrt'), + path.join(kitsBase, 'Include', sdkVer, 'um'), + path.join(kitsBase, 'Include', sdkVer, 'shared'), + ); + libs.push( + path.join(kitsBase, 'Lib', sdkVer, 'ucrt', 'x64'), + path.join(kitsBase, 'Lib', sdkVer, 'um', 'x64'), + ); + } + const incArgs = incs.map(p => `/I"${p}"`).join(' '); + const libArgs = libs.map(p => `/LIBPATH:"${p}"`).join(' '); + return { + bin: `"${clPath}"`, + flagsFor: (out, src, libNames) => + `/nologo /O2 ${incArgs} /Fe:"${out}" "${src}" /link ${libArgs} ${libNames.map(l => `${l}.lib`).join(' ')}`, + }; + } + + // Search standard VS install locations for cl.exe (x64 host, x64 target). + function findMsvcCl() { + const roots = [ + 'C:\\Program Files\\Microsoft Visual Studio', + 'C:\\Program Files (x86)\\Microsoft Visual Studio', + ]; + const editions = ['Community', 'Professional', 'Enterprise', 'BuildTools']; + const years = ['2022', '2019', '2017']; + for (const root of roots) { + for (const year of years) { + for (const edition of editions) { + const msvcBase = path.join(root, year, edition, 'VC', 'Tools', 'MSVC'); + if (!fs.existsSync(msvcBase)) continue; + const versions = fs.readdirSync(msvcBase).sort().reverse(); + for (const ver of versions) { + const cl = path.join(msvcBase, ver, 'bin', 'Hostx64', 'x64', 'cl.exe'); + if (fs.existsSync(cl)) return cl; + } + } + } + } + return null; + } + + // 1. gcc / clang on PATH (MinGW-w64, LLVM, Git for Windows SDK) + for (const bin of ['gcc', 'clang']) { + try { + probe(`${bin} --version`, { stdio: 'pipe' }); + return { + bin, + flagsFor: (out, src, libNames) => + `-O2 -o "${out}" "${src}" ${libNames.map(l => `-l${l}`).join(' ')}`, + }; + } catch {} + } + + // 2. cl.exe on PATH (Developer Command Prompt / vcvarsall) + try { + probe('cl 2>&1', { stdio: 'pipe', shell: true }); + return { + bin: 'cl', + flagsFor: (out, src, libNames) => + `/nologo /O2 /Fe:"${out}" "${src}" /link ${libNames.map(l => `${l}.lib`).join(' ')}`, + }; + } catch {} + + // 3. cl.exe in default VS installation (most Windows dev machines) + const msvcCl = findMsvcCl(); + if (msvcCl) { + console.log(`[build-native] Found MSVC cl.exe at: ${msvcCl}`); + return makeMsvcCompiler(msvcCl); + } + + return null; + } + + const compiler = findCCompiler(); + if (!compiler) { + console.warn( + '[build-native] WARNING: No C compiler found (gcc, clang, or MSVC cl.exe).', + 'hotkey-hold-monitor.exe will not be built.', + 'The app will still run; hold-to-talk will be disabled.', + 'Install MinGW-w64 (scoop install gcc) or Visual Studio 2017+ to enable it.' + ); + console.log('[build-native] Done (Windows — native binaries skipped).'); + process.exit(0); + } + + const binaries = [ + { + out: 'hotkey-hold-monitor.exe', + 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) { + const outPath = path.join(outDir, out); + 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.'); + } + } + + // ── C# binaries (compiled with csc.exe from .NET Framework — always available on Windows 10/11) ── + function findCsc() { + // Try .NET Framework 4.x csc.exe (guaranteed on Windows 10/11) + const cscPaths = [ + 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe', + 'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe', + ]; + for (const p of cscPaths) { + if (fs.existsSync(p)) return p; + } + return null; + } + + // System.Speech.dll lives in the WPF subfolder of the .NET Framework directory. + function findSystemSpeechDll(cscPath) { + const netDir = path.dirname(cscPath); // e.g. C:\Windows\Microsoft.NET\Framework64\v4.0.30319 + const wpfPath = path.join(netDir, 'WPF', 'System.Speech.dll'); + if (fs.existsSync(wpfPath)) return wpfPath; + return null; + } + + const cscBinaries = [ + { + out: 'speech-recognizer.exe', + src: 'src/native/speech-recognizer.cs', + }, + ]; + + const csc = findCsc(); + if (!csc) { + console.warn('[build-native] WARNING: csc.exe not found — speech-recognizer.exe will not be built.'); + console.warn('[build-native] Dictation will fall back to cloud transcription if an API key is configured.'); + } else { + const speechDll = findSystemSpeechDll(csc); + if (!speechDll) { + console.warn('[build-native] WARNING: System.Speech.dll not found — speech-recognizer.exe will not be built.'); + } else { + for (const { out, src } of cscBinaries) { + const outPath = path.join(outDir, out); + const srcPath = path.join(__dirname, '..', src); // absolute path required by csc.exe + const cmd = `"${csc}" /nologo /target:exe /optimize+ /r:"${speechDll}" /out:"${outPath}" "${srcPath}"`; + console.log(`[build-native] Compiling ${out} with csc.exe...`); + try { + execSync(cmd, { stdio: 'inherit' }); + } catch (err) { + console.warn(`[build-native] WARNING: Failed to compile ${out}:`, err.message); + console.warn('[build-native] Dictation will fall back to cloud transcription if an API key is configured.'); + } + } + } + } + + console.log('[build-native] Done (Windows).'); + process.exit(0); +} + +// ── Other platforms ──────────────────────────────────────────────────────── + +console.log(`[build-native] No native binaries for ${process.platform} — skipping.`); diff --git a/scripts/launch-electron.js b/scripts/launch-electron.js new file mode 100644 index 0000000..7982779 --- /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/__tests__/settings-store.test.ts b/src/main/__tests__/settings-store.test.ts new file mode 100644 index 0000000..95e12aa --- /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/__tests__/snippet-store.test.ts b/src/main/__tests__/snippet-store.test.ts new file mode 100644 index 0000000..174cf9e --- /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/main/commands.ts b/src/main/commands.ts index 59d7420..7bd31ba 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -581,9 +581,228 @@ function buildSettingsKeywords( return Array.from(set); } +// ─── Windows: Application Discovery ───────────────────────────────── + +const WIN_APP_SKIP_RE = /uninstall|uninst|^setup\s/i; + +async function discoverWindowsApplications(): Promise { + const startMenuDirs = [ + path.join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'), + path.join(process.env.ProgramData || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs'), + ].filter(Boolean); + + // Single PowerShell invocation: resolve all .lnk shortcuts to exe target paths. + // Uses WScript.Shell COM object; single-quotes in paths are doubled (PS convention). + const psScript = `$shell=New-Object -ComObject WScript.Shell +$res=@() +$dirs=@(${startMenuDirs.map((d) => `'${d.replace(/'/g, "''")}'`).join(',')}) +foreach($d in $dirs){ + if(Test-Path $d){ + Get-ChildItem $d -Recurse -Filter *.lnk | ForEach-Object { + try { + $sc=$shell.CreateShortcut($_.FullName) + $t=$sc.TargetPath + if($t -and $t -match '\\.exe$' -and $t -notmatch 'WindowsApps' -and (Test-Path $t)){ + $res+=[PSCustomObject]@{n=$_.BaseName;p=$t} + } + } catch {} + } + } +} +if($res.Count -eq 0){Write-Output '[]';exit} +($res | Group-Object p | ForEach-Object { $_.Group[0] }) | ConvertTo-Json -Compress`; + + const encoded = Buffer.from(psScript, 'utf16le').toString('base64'); + let apps: Array<{ n: string; p: string }> = []; + try { + const { stdout } = await execAsync( + `powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`, + { timeout: 30_000 } + ); + const raw = stdout.trim(); + if (raw && raw !== '[]') { + const parsed = JSON.parse(raw); + apps = Array.isArray(parsed) ? parsed : [parsed]; + } + } catch (e) { + console.warn('[Win] Start Menu scan failed:', e); + } + + const results: CommandInfo[] = []; + for (const item of apps) { + const name = String(item?.n || '').trim(); + const exePath = String(item?.p || '').trim(); + if (!name || !exePath) continue; + if (WIN_APP_SKIP_RE.test(name)) continue; + + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app'; + const idSuffix = crypto.createHash('md5').update(exePath).digest('hex').slice(0, 8); + const id = `win-app-${slug}-${idSuffix}`; + + results.push({ + id, + title: name, + keywords: [name.toLowerCase()], + iconDataUrl: getCachedIcon(exePath), + category: 'app' as const, + path: exePath, + _bundlePath: exePath, + }); + } + + // ── 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; +} + +/** + * Batch-extract icons from Windows .exe files via PowerShell + System.Drawing. + * Returns a map of exePath → "data:image/png;base64,…". + */ +async function extractWindowsIcons(exePaths: string[]): Promise> { + const result = new Map(); + if (exePaths.length === 0) return result; + + // Single-quote each path; escape embedded single quotes by doubling. + const pathsPs = exePaths.map((p) => `'${p.replace(/'/g, "''")}'`).join(','); + const psScript = `Add-Type -AssemblyName System.Drawing +$paths=@(${pathsPs}) +$r=[ordered]@{} +foreach($p in $paths){ + try{ + $ic=[System.Drawing.Icon]::ExtractAssociatedIcon($p) + $bm=$ic.ToBitmap() + $ms=New-Object System.IO.MemoryStream + $bm.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png) + $r[$p]=[Convert]::ToBase64String($ms.ToArray()) + $ic.Dispose();$bm.Dispose();$ms.Dispose() + }catch{$r[$p]=''} +} +$r|ConvertTo-Json -Compress`; + + const encoded = Buffer.from(psScript, 'utf16le').toString('base64'); + try { + const { stdout } = await execAsync( + `powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`, + { timeout: 60_000 } + ); + const raw = stdout.trim(); + if (raw) { + const parsed: Record = JSON.parse(raw); + for (const [exePath, b64] of Object.entries(parsed)) { + if (typeof b64 === 'string' && b64) { + result.set(exePath, `data:image/png;base64,${b64}`); + } + } + } + } catch (e) { + console.warn('[Win] Icon batch extraction failed:', e); + } + return result; +} + +// ─── Windows: System Settings Discovery ────────────────────────────── + +const WINDOWS_SETTINGS_PANELS: Array<{ + id: string; title: string; path: string; keywords: string[]; iconEmoji: string; +}> = [ + { id: 'win-settings-display', title: 'Display', path: 'ms-settings:display', keywords: ['display','screen','resolution','brightness','monitor','refresh rate'], iconEmoji: '🖥️' }, + { id: 'win-settings-nightlight', title: 'Night Light', path: 'ms-settings:nightlight', keywords: ['night light','blue light','eye strain','warm','color temperature'], iconEmoji: '🌙' }, + { id: 'win-settings-sound', title: 'Sound', path: 'ms-settings:sound', keywords: ['sound','audio','volume','speakers','microphone','headphones','output'], iconEmoji: '🔊' }, + { id: 'win-settings-bluetooth', title: 'Bluetooth & Devices', path: 'ms-settings:bluetooth', keywords: ['bluetooth','devices','connect','pair','wireless','mouse','keyboard','printer'], iconEmoji: '📡' }, + { id: 'win-settings-network', title: 'Network & Internet', path: 'ms-settings:network-status', keywords: ['network','wifi','internet','ethernet','vpn','proxy','connection','status'], iconEmoji: '🌐' }, + { id: 'win-settings-wifi', title: 'Wi-Fi', path: 'ms-settings:network-wifi', keywords: ['wifi','wireless','network','connect','internet','ssid'], iconEmoji: '📶' }, + { id: 'win-settings-vpn', title: 'VPN', path: 'ms-settings:network-vpn', keywords: ['vpn','network','private','tunnel','secure'], iconEmoji: '🔒' }, + { id: 'win-settings-personalization', title: 'Personalization', path: 'ms-settings:personalization', keywords: ['personalization','wallpaper','theme','background','colors','dark mode'], iconEmoji: '🎨' }, + { id: 'win-settings-background', title: 'Background', path: 'ms-settings:personalization-background', keywords: ['background','wallpaper','desktop','picture','slideshow'], iconEmoji: '🖼️' }, + { id: 'win-settings-colors', title: 'Colors & Themes', path: 'ms-settings:colors', keywords: ['colors','themes','accent','dark mode','light mode','appearance','transparent'], iconEmoji: '🎨' }, + { id: 'win-settings-taskbar', title: 'Taskbar', path: 'ms-settings:taskbar', keywords: ['taskbar','start','notification area','system tray','pinned'], iconEmoji: '📌' }, + { id: 'win-settings-apps', title: 'Apps & Features', path: 'ms-settings:appsfeatures', keywords: ['apps','features','uninstall','programs','install','remove'], iconEmoji: '📦' }, + { id: 'win-settings-defaultapps', title: 'Default Apps', path: 'ms-settings:defaultapps', keywords: ['default','apps','browser','email','media','association','open with'], iconEmoji: '✅' }, + { id: 'win-settings-startup', title: 'Startup Apps', path: 'ms-settings:startupapps', keywords: ['startup','autostart','boot','apps','login','launch on start'], iconEmoji: '🚀' }, + { id: 'win-settings-accounts', title: 'Accounts', path: 'ms-settings:accounts', keywords: ['accounts','sign-in','email','sync','profile','microsoft account'], iconEmoji: '👤' }, + { id: 'win-settings-signin', title: 'Sign-in Options', path: 'ms-settings:signinoptions', keywords: ['sign-in','password','pin','fingerprint','face','hello','lock screen'], iconEmoji: '🔑' }, + { id: 'win-settings-datetime', title: 'Date & Time', path: 'ms-settings:dateandtime', keywords: ['date','time','timezone','clock','calendar','synchronize'], iconEmoji: '🕐' }, + { id: 'win-settings-language', title: 'Language & Region', path: 'ms-settings:regionformatting', keywords: ['language','region','locale','format','keyboard layout','input'], iconEmoji: '🌍' }, + { id: 'win-settings-notifications', title: 'Notifications', path: 'ms-settings:notifications', keywords: ['notifications','focus assist','do not disturb','alerts','banners','sounds'], iconEmoji: '🔔' }, + { id: 'win-settings-battery', title: 'Battery & Power', path: 'ms-settings:batterysaver', keywords: ['battery','power','charge','sleep','hibernate','energy','saver'], iconEmoji: '🔋' }, + { id: 'win-settings-storage', title: 'Storage', path: 'ms-settings:storagesense', keywords: ['storage','disk','space','cleanup','drive','files','free up'], iconEmoji: '💾' }, + { id: 'win-settings-multitasking', title: 'Multitasking', path: 'ms-settings:multitasking', keywords: ['multitasking','snap','virtual desktop','window','alt-tab','split'], iconEmoji: '🪟' }, + { id: 'win-settings-privacy', title: 'Privacy & Security', path: 'ms-settings:privacy', keywords: ['privacy','security','permissions','location','camera','microphone','data'], iconEmoji: '🔐' }, + { id: 'win-settings-mic', title: 'Microphone Privacy', path: 'ms-settings:privacy-microphone', keywords: ['microphone','privacy','mic','recording','permissions','voice'], iconEmoji: '🎙️' }, + { id: 'win-settings-camera', title: 'Camera Privacy', path: 'ms-settings:privacy-webcam', keywords: ['camera','webcam','privacy','video','permissions'], iconEmoji: '📷' }, + { id: 'win-settings-location', title: 'Location', path: 'ms-settings:privacy-location', keywords: ['location','gps','privacy','maps','where','geolocation'], iconEmoji: '📍' }, + { id: 'win-settings-update', title: 'Windows Update', path: 'ms-settings:windowsupdate', keywords: ['update','windows update','patch','upgrade','security updates'], iconEmoji: '🔄' }, + { id: 'win-settings-troubleshoot', title: 'Troubleshoot', path: 'ms-settings:troubleshoot', keywords: ['troubleshoot','fix','repair','help','diagnose','wizard'], iconEmoji: '🔧' }, + { id: 'win-settings-recovery', title: 'Recovery', path: 'ms-settings:recovery', keywords: ['recovery','reset','restore','reinstall','factory reset'], iconEmoji: '♻️' }, + { id: 'win-settings-activation', title: 'Activation', path: 'ms-settings:activation', keywords: ['activation','license','product key','genuine','windows license'], iconEmoji: '🔑' }, + { id: 'win-settings-developer', title: 'Developer Mode', path: 'ms-settings:developers', keywords: ['developer','dev mode','sideload','debug','terminal','advanced'], iconEmoji: '💻' }, + { id: 'win-settings-mouse', title: 'Mouse', path: 'ms-settings:mousetouchpad', keywords: ['mouse','cursor','pointer','scroll','click','buttons'], iconEmoji: '🖱️' }, + { id: 'win-settings-keyboard', title: 'Keyboard', path: 'ms-settings:keyboard', keywords: ['keyboard','shortcut','typing','input','layout','accessibility'], iconEmoji: '⌨️' }, + { id: 'win-settings-printer', title: 'Printers & Scanners', path: 'ms-settings:printers', keywords: ['printer','scanner','print','fax','output device'], iconEmoji: '🖨️' }, + { id: 'win-settings-gaming', title: 'Gaming', path: 'ms-settings:gaming-gamebar', keywords: ['gaming','game bar','xbox','game mode','capture','fps','overlay'], iconEmoji: '🎮' }, + { id: 'win-settings-optional', title: 'Optional Features', path: 'ms-settings:optionalfeatures', keywords: ['optional','features','windows features','components','telnet','ssh'], iconEmoji: '⚙️' }, + { id: 'win-settings-about', title: 'About This PC', path: 'ms-settings:about', keywords: ['about','pc','specs','ram','cpu','version','system info','computer name'], iconEmoji: 'ℹ️' }, +]; + +function discoverWindowsSystemSettings(): CommandInfo[] { + return WINDOWS_SETTINGS_PANELS.map((s) => ({ + id: s.id, + title: s.title, + keywords: s.keywords, + iconEmoji: s.iconEmoji, + category: 'settings' as const, + path: s.path, + })); +} + // ─── Application Discovery ────────────────────────────────────────── async function discoverApplications(): Promise { + if (process.platform === 'win32') return discoverWindowsApplications(); + const results: CommandInfo[] = []; const usedIds = new Set(); @@ -673,6 +892,8 @@ async function discoverApplications(): Promise { // ─── System Settings Discovery ────────────────────────────────────── async function discoverSystemSettings(): Promise { + if (process.platform === 'win32') return discoverWindowsSystemSettings(); + const results: CommandInfo[] = []; const seen = new Set(); @@ -828,10 +1049,34 @@ async function discoverSystemSettings(): Promise { // ─── Command Execution ────────────────────────────────────────────── async function openAppByPath(appPath: string): Promise { + if (process.platform === 'win32') { + const { shell } = require('electron'); + // 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:')) { + 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); + if (err) throw new Error(err); + return; + } await execAsync(`open "${appPath}"`); } async function openSettingsPane(identifier: string): Promise { + if (process.platform === 'win32') { + // ms-settings: URIs are opened via shell.openExternal which routes through + // Windows URI handler → Settings app. + const { shell } = require('electron'); + await shell.openExternal(identifier); + return; + } + if (identifier.startsWith('com.apple.')) { try { await execAsync(`open "x-apple.systempreferences:${identifier}"`); @@ -982,6 +1227,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 @@ -1033,7 +1334,7 @@ async function discoverAndBuildCommands(): Promise { const allCommands = [...apps, ...settings, ...extensionCommands, ...scriptCommands, ...systemCommands]; - // ── Batch-extract icons via NSWorkspace for app/settings bundles ── + // ── Batch-extract icons for app/settings bundles that don't have one yet ── const bundlesNeedingIcon = allCommands.filter( (c) => !c.iconDataUrl && @@ -1042,14 +1343,33 @@ async function discoverAndBuildCommands(): Promise { ); if (bundlesNeedingIcon.length > 0) { - console.log(`Extracting ${bundlesNeedingIcon.length} app/settings icons via NSWorkspace…`); - const bundlePaths = Array.from(new Set(bundlesNeedingIcon.map((c) => c._bundlePath!))); - const iconMap = await batchGetIconsViaWorkspace(bundlePaths); - - for (const cmd of bundlesNeedingIcon) { - const dataUrl = iconMap.get(cmd._bundlePath!); - if (dataUrl) { - cmd.iconDataUrl = dataUrl; + if (process.platform === 'win32') { + // Windows: extract icons from .exe files via PowerShell + System.Drawing. + const allPaths = Array.from(new Set(bundlesNeedingIcon.map((c) => c._bundlePath!))); + console.log(`Extracting ${allPaths.length} Windows app icons via System.Drawing…`); + const BATCH = 20; + for (let i = 0; i < allPaths.length; i += BATCH) { + const batchPaths = allPaths.slice(i, i + BATCH); + const iconMap = await extractWindowsIcons(batchPaths); + for (const cmd of bundlesNeedingIcon) { + if (!cmd._bundlePath || cmd.iconDataUrl) continue; + const dataUrl = iconMap.get(cmd._bundlePath); + if (dataUrl) { + cmd.iconDataUrl = dataUrl; + setCachedIcon(cmd._bundlePath, dataUrl); + } + } + } + } else { + // macOS: use NSWorkspace JXA batch extraction. + console.log(`Extracting ${bundlesNeedingIcon.length} app/settings icons via NSWorkspace…`); + const bundlePaths = Array.from(new Set(bundlesNeedingIcon.map((c) => c._bundlePath!))); + const iconMap = await batchGetIconsViaWorkspace(bundlePaths); + for (const cmd of bundlesNeedingIcon) { + const dataUrl = iconMap.get(cmd._bundlePath!); + if (dataUrl) { + cmd.iconDataUrl = dataUrl; + } } } } diff --git a/src/main/main.ts b/src/main/main.ts index a86ff38..36929f7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -60,13 +60,25 @@ 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; +const { app, BrowserWindow, globalShortcut, ipcMain, screen, shell, Menu, Tray, nativeImage, protocol, net, dialog, systemPreferences, nativeTheme, clipboard: systemClipboard } = electron; try { app.setName('SuperCmd'); } catch {} +// Force dark mode so native form controls (selects, scrollbars, etc.) render +// correctly on Windows regardless of the user's system light/dark setting. +try { nativeTheme.themeSource = 'dark'; } catch {} + +// Set the App User Model ID so Windows taskbar groups all SuperCmd windows +// under one icon and shows the correct name in alt-tab / taskbar previews. +if (process.platform === 'win32') { + try { app.setAppUserModelId('com.supercmd.app'); } catch {} +} + // ─── Native Binary Helpers ────────────────────────────────────────── /** @@ -88,7 +100,7 @@ function getNativeBinaryPath(name: string): string { const DEFAULT_WINDOW_WIDTH = 800; const DEFAULT_WINDOW_HEIGHT = 500; const ONBOARDING_WINDOW_WIDTH = 1120; -const ONBOARDING_WINDOW_HEIGHT = 740; +const ONBOARDING_WINDOW_HEIGHT = 680; const CURSOR_PROMPT_WINDOW_WIDTH = 500; const CURSOR_PROMPT_WINDOW_HEIGHT = 90; const CURSOR_PROMPT_LEFT_OFFSET = 20; @@ -353,7 +365,7 @@ const fnCommandWatcherConfigs = new Map(); 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 = ''; @@ -489,14 +501,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') { @@ -512,92 +517,15 @@ 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 { - 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, @@ -968,23 +896,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 { @@ -1024,9 +936,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: { @@ -1687,6 +1597,85 @@ function fetchEdgeTtsVoiceCatalog(timeoutMs = 12000): Promise { const allowClipboardFallback = options?.allowClipboardFallback !== false; const clipboardWaitMs = Math.max(0, Number(options?.clipboardWaitMs ?? 380) || 380); + + // ── 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); + + // 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 { + 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) { + 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'); @@ -2018,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 {} @@ -2026,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; @@ -2039,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) { @@ -2048,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); @@ -2060,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; @@ -2108,50 +2144,26 @@ 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'); 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) => { @@ -2232,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, @@ -2257,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 || {})) { @@ -2288,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'); @@ -2320,6 +2357,14 @@ function ensureWhisperHoldWatcherBinary(): string | 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); @@ -2327,25 +2372,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 = ''; @@ -2634,6 +2665,7 @@ function createWindow(): void { backgroundColor: '#00000000', vibrancy: 'fullscreen-ui', visualEffectState: 'active', + icon: process.platform === 'win32' ? path.join(__dirname, '../../supercmd.ico') : undefined, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -3259,18 +3291,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, @@ -3319,6 +3353,52 @@ function setLauncherMode(mode: LauncherMode): void { } function captureFrontmostAppContext(): void { + if (process.platform === 'win32') { + try { + const { execFile } = 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('; '); + 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 + } + return; + } if (process.platform !== 'darwin') return; try { const { execFileSync } = require('child_process'); @@ -3505,6 +3585,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', [ @@ -3549,6 +3650,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, '\\"') @@ -3578,10 +3705,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); @@ -3604,14 +3742,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); @@ -3633,14 +3777,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) { @@ -3678,6 +3828,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; @@ -3720,16 +3887,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(() => { @@ -3750,7 +3927,6 @@ function stopSnippetExpander(): void { } function refreshSnippetExpander(): void { - if (process.platform !== 'darwin') return; stopSnippetExpander(); const keywords = getAllSnippets() @@ -3759,28 +3935,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) => { @@ -4163,11 +4320,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') { @@ -4491,7 +4648,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(); @@ -4944,6 +5111,7 @@ function openSettingsWindow(payload?: SettingsNavigationPayload): void { vibrancy: 'hud', visualEffectState: 'active', show: false, + icon: process.platform === 'win32' ? path.join(__dirname, '../../supercmd.ico') : undefined, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -5012,6 +5180,7 @@ function openExtensionStoreWindow(): void { vibrancy: 'hud', visualEffectState: 'active', show: false, + icon: process.platform === 'win32' ? path.join(__dirname, '../../supercmd.ico') : undefined, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -5735,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') { @@ -5912,13 +6086,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); @@ -7032,6 +7215,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', @@ -7075,6 +7293,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'); @@ -7145,6 +7414,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 @@ -7172,6 +7481,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 = ` @@ -7193,6 +7539,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'], { @@ -7611,7 +7960,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 = getDialogParentWindow(event); + const result = await importSnippetsFromFile(parent); refreshSnippetExpander(); return result; } finally { @@ -7622,7 +7972,8 @@ return appURL's |path|() as text`, ipcMain.handle('snippet-export', async (event: any) => { suppressBlurHide = true; try { - return await exportSnippetsToFile(getDialogParentWindow(event)); + const parent = getDialogParentWindow(event); + return await exportSnippetsToFile(parent); } finally { suppressBlurHide = false; } @@ -7828,9 +8179,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); @@ -7901,23 +8257,44 @@ return appURL's |path|() as text`, } const lang = language || loadSettings().ai.speechLanguage || 'en-US'; - const binaryPath = getNativeBinaryPath('speech-recognizer'); + // On Windows the binary has an .exe extension; on macOS it has none. + const binaryName = process.platform === 'win32' ? 'speech-recognizer.exe' : 'speech-recognizer'; + const binaryPath = getNativeBinaryPath(binaryName); const fs = require('fs'); - // Compile on demand (same pattern as color-picker / snippet-expander) + // Compile on demand if binary is missing. if (!fs.existsSync(binaryPath)) { try { const { execFileSync } = require('child_process'); - const sourcePath = path.join(app.getAppPath(), 'src', 'native', 'speech-recognizer.swift'); - execFileSync('swiftc', [ - '-O', '-o', binaryPath, sourcePath, - '-framework', 'Speech', - '-framework', 'AVFoundation', - ]); + if (process.platform === 'win32') { + // Compile the C# speech recognizer with .NET Framework csc.exe. + const cscCandidates = [ + 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe', + 'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe', + ]; + const csc = cscCandidates.find((p) => fs.existsSync(p)); + if (!csc) throw new Error('csc.exe not found — run `npm run build:native` after installing .NET Framework.'); + const sourcePath = path.join(app.getAppPath(), 'src', 'native', 'speech-recognizer.cs'); + // System.Speech.dll lives in the WPF subfolder next to csc.exe. + const netDir = path.dirname(csc); + const speechDll = path.join(netDir, 'WPF', 'System.Speech.dll'); + execFileSync(csc, ['/nologo', '/target:exe', '/optimize+', `/r:${speechDll}`, `/out:${binaryPath}`, sourcePath]); + } else { + const sourcePath = path.join(app.getAppPath(), 'src', 'native', 'speech-recognizer.swift'); + execFileSync('swiftc', [ + '-O', '-o', binaryPath, sourcePath, + '-framework', 'Speech', + '-framework', 'AVFoundation', + ]); + } console.log('[Whisper][native] Compiled speech-recognizer binary'); } catch (error) { console.error('[Whisper][native] Compile failed:', error); - throw new Error('Failed to compile native speech recognizer. Ensure Xcode Command Line Tools are installed.'); + throw new Error( + process.platform === 'win32' + ? 'Failed to compile Windows speech recognizer. Run `npm run build:native` to build it.' + : 'Failed to compile native speech recognizer. Ensure Xcode Command Line Tools are installed.' + ); } } @@ -8364,95 +8741,29 @@ 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; } nativeColorPickerPromise = (async () => { - const { execFile, execFileSync } = require('child_process'); - const fsNative = require('fs'); - const colorPickerPath = getNativeBinaryPath('color-picker'); - - // Build on demand in development when binary artifacts are missing. - if (!fsNative.existsSync(colorPickerPath)) { + suppressBlurHide = true; try { - const sourceCandidates = [ - path.join(app.getAppPath(), 'src', 'native', 'color-picker.swift'), - path.join(process.cwd(), 'src', 'native', 'color-picker.swift'), - path.join(__dirname, '..', '..', 'src', 'native', 'color-picker.swift'), - ]; - const sourcePath = sourceCandidates.find((candidate: string) => fsNative.existsSync(candidate)); - if (!sourcePath) { - console.warn('[ColorPicker] Binary and source file not found.'); - return null; - } - fsNative.mkdirSync(path.dirname(colorPickerPath), { recursive: true }); - execFileSync('swiftc', ['-O', '-o', colorPickerPath, sourcePath, '-framework', 'AppKit']); - } catch (error) { - console.error('[ColorPicker] Failed to compile native helper:', error); - return null; + return await platform.pickColor(); + } finally { + suppressBlurHide = false; } - } - - // Keep the launcher open while the native picker is focused. - suppressBlurHide = true; - try { - 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 parsedColor = JSON.parse(trimmed); - if (!parsedColor || typeof parsedColor !== 'object') { - resolve(null); - return; - } - - const toUnitRange = (value: unknown): number | null => { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - if (numeric > 1) { - const normalized = numeric / 255; - return Math.max(0, Math.min(1, normalized)); - } - return Math.max(0, Math.min(1, numeric)); - }; - - const red = toUnitRange((parsedColor as any).red); - const green = toUnitRange((parsedColor as any).green); - const blue = toUnitRange((parsedColor as any).blue); - const alpha = toUnitRange((parsedColor as any).alpha ?? 1); - if (red === null || green === null || blue === null || alpha === null) { - resolve(null); - return; - } - - const colorSpace = typeof (parsedColor as any).colorSpace === 'string' && (parsedColor as any).colorSpace.trim() - ? String((parsedColor as any).colorSpace) - : 'srgb'; - - resolve({ red, green, blue, alpha, colorSpace }); - } catch (e) { - console.error('Failed to parse color picker output:', e); - resolve(null); - } - }); - }); - return pickedColor; - } finally { - suppressBlurHide = false; - } })(); try { diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts new file mode 100644 index 0000000..5071e03 --- /dev/null +++ b/src/main/platform/darwin.ts @@ -0,0 +1,214 @@ +/** + * 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'; + +import { app, systemPreferences } 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; +} + +// ── 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', () => { + 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); + }); + }); + }, + + 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 + ): ChildProcess | null { + const fs = require('fs'); + 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; + } + } + + 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.alt ? '1' : '0', + modifiers.shift ? '1' : '0', + modifiers.fn ? '1' : '0', + ], + { 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 0000000..3f95af7 --- /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 0000000..d6ac857 --- /dev/null +++ b/src/main/platform/interface.ts @@ -0,0 +1,88 @@ +/** + * 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; + requested: boolean; + status: MicrophoneAccessStatus; + canPrompt: 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; + alt: boolean; + shift: boolean; + fn: 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 + ): 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 0000000..a985dfd --- /dev/null +++ b/src/main/platform/windows.ts @@ -0,0 +1,222 @@ +/** + * platform/windows.ts + * + * 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, + MicrophonePermissionResult, + LocalSpeakBackend, + HotkeyModifiers, +} from './interface'; + +import { app } from 'electron'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getNativeBinaryPath(name: string): string { + // windows.ts compiles to dist/main/platform/windows.js, so __dirname is + // dist/main/platform — go up two levels to reach dist/, then into native/. + 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 + // 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 + ): 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[]): 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 { + // 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/main/script-command-runner.ts b/src/main/script-command-runner.ts index b8eeede..a91600f 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/main/settings-store.ts b/src/main/settings-store.ts index 7d53c9a..a91b776 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/native/hotkey-hold-monitor.c b/src/native/hotkey-hold-monitor.c new file mode 100644 index 0000000..eeac2f4 --- /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; +} diff --git a/src/native/snippet-expander-win.c b/src/native/snippet-expander-win.c new file mode 100644 index 0000000..82a7d2e --- /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/native/speech-recognizer.cs b/src/native/speech-recognizer.cs new file mode 100644 index 0000000..191dda7 --- /dev/null +++ b/src/native/speech-recognizer.cs @@ -0,0 +1,105 @@ +/** + * src/native/speech-recognizer.cs + * + * Windows speech recognizer using System.Speech (built into .NET Framework 4.x, + * available on every Windows 10/11 machine — no API key, works offline). + * + * Usage: speech-recognizer.exe [lang] + * lang BCP-47 language tag, e.g. en-US (default). + * + * Stdout protocol (one JSON object per line, same as the macOS Swift binary): + * {"ready":true} – recognizer is listening + * {"transcript":"text","isFinal":true|false} – speech result + * {"error":"message"} – fatal error then exit + * + * The process runs until killed by the parent (node sends SIGTERM / TerminateProcess). + */ + +using System; +using System.Globalization; +using System.Speech.Recognition; +using System.Threading; + +class SpeechRecognizerProgram +{ + static string Escape(string s) + { + return (s ?? string.Empty) + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r"); + } + + static void Emit(string json) + { + Console.WriteLine(json); + Console.Out.Flush(); + } + + static void Main(string[] args) + { + string lang = args.Length > 0 ? args[0].Trim() : "en-US"; + + // Try the requested culture, fall back to en-US, then system default. + SpeechRecognitionEngine engine = null; + string[] candidates = { lang, "en-US", string.Empty }; + foreach (string id in candidates) + { + try + { + engine = id.Length == 0 + ? new SpeechRecognitionEngine() + : new SpeechRecognitionEngine(new CultureInfo(id)); + break; + } + catch { /* try next */ } + } + + if (engine == null) + { + Emit("{\"error\":\"No speech recognition engine is available on this system.\"}"); + return; + } + + try + { + engine.SetInputToDefaultAudioDevice(); + } + catch (Exception ex) + { + Emit("{\"error\":\"" + Escape(ex.Message) + "\"}"); + engine.Dispose(); + return; + } + + engine.LoadGrammar(new DictationGrammar()); + + // Interim / hypothesis — sent while the user is still speaking. + engine.SpeechHypothesized += (sender, e) => + { + Emit("{\"transcript\":\"" + Escape(e.Result.Text) + "\",\"isFinal\":false}"); + }; + + // Final result — sent when the engine has committed to a result. + engine.SpeechRecognized += (sender, e) => + { + Emit("{\"transcript\":\"" + Escape(e.Result.Text) + "\",\"isFinal\":true}"); + }; + + // Low-confidence results are silently ignored. + engine.SpeechRecognitionRejected += (sender, e) => { }; + + engine.RecognizeAsync(RecognizeMode.Multiple); + + // Signal the parent that we are ready. + Emit("{\"ready\":true}"); + + // Block the main thread forever — the SAPI engine delivers events on + // background threads. The parent kills this process via TerminateProcess + // when it wants to stop recognition. + Thread.Sleep(Timeout.Infinite); + + engine.Dispose(); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 0cef1b6..8ff7c31 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,6 +3,7 @@ + SuperCmd diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 48af6b3..baef74d 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,8 +57,306 @@ 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; + primaryModifierLabel: string; + onOpenActions: () => void; +}; + +/** + * Renders bottom action/status bar for the launcher list mode. + */ +const LauncherFooter: React.FC = ({ + isLoading, + memoryActionLoading, + memoryFeedback, + selectedCommand, + displayCommandsCount, + primaryAction, + primaryModifierLabel, + 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 isMac = window.electron.platform === 'darwin'; + const primaryModifierLabel = isMac ? '⌘' : 'Ctrl'; const [commands, setCommands] = useState([]); const [commandAliases, setCommandAliases] = useState>({}); const [pinnedCommands, setPinnedCommands] = useState([]); @@ -90,7 +395,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 +425,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 +517,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 +533,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 +742,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; }, []); @@ -711,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; @@ -732,7 +1050,7 @@ const App: React.FC = () => { window.addEventListener('keydown', onWindowKeyDown, true); return () => window.removeEventListener('keydown', onWindowKeyDown, true); - }, [isLauncherModeActive]); + }, [isLauncherModeActive, isPrimaryModifierPressed]); useEffect(() => { return () => { @@ -785,35 +1103,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( () => [ @@ -904,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); @@ -933,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; @@ -1007,6 +1323,7 @@ const App: React.FC = () => { }, [ moveSelection, + isPrimaryModifierPressed, displayCommands, selectedIndex, searchQuery, @@ -1669,9 +1986,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 +2006,8 @@ const App: React.FC = () => { { setShowClipboardManager(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} /> @@ -1732,9 +2047,8 @@ const App: React.FC = () => { initialView={showSnippetManager} onClose={() => { setShowSnippetManager(null); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} /> @@ -1753,9 +2067,8 @@ const App: React.FC = () => { { setShowFileSearch(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} /> @@ -1802,9 +2115,8 @@ const App: React.FC = () => { setShowOnboarding(false); setShowWhisperOnboarding(false); setOnboardingRequiresShortcutFix(false); - setSearchQuery(''); - setSelectedIndex(0); - setTimeout(() => inputRef.current?.focus(), 50); + resetLauncherSelection(); + focusSearchInputSoon(); }} /> @@ -1849,197 +2161,40 @@ 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/ClipboardManager.tsx b/src/renderer/src/ClipboardManager.tsx index e9d99fb..7f0ab14 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 37d1446..62deae2 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/OnboardingExtension.tsx b/src/renderer/src/OnboardingExtension.tsx index 280ca7b..da0edd3 100644 --- a/src/renderer/src/OnboardingExtension.tsx +++ b/src/renderer/src/OnboardingExtension.tsx @@ -138,15 +138,17 @@ 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>({}); const [requestedPermissions, setRequestedPermissions] = useState>({}); const [permissionLoading, setPermissionLoading] = useState>({}); const [permissionNotes, setPermissionNotes] = useState>({}); - const [whisperHoldKey, setWhisperHoldKey] = useState('Fn'); + const DEFAULT_WHISPER_HOTKEY = isWindows ? 'Ctrl+Shift+Space' : 'Fn'; + const [whisperHoldKey, setWhisperHoldKey] = useState(DEFAULT_WHISPER_HOTKEY); const [whisperKeyStatus, setWhisperKeyStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [isHoldKeyActive, setIsHoldKeyActive] = useState(false); const [speechLanguage, setSpeechLanguage] = useState('en-US'); @@ -169,8 +171,8 @@ const OnboardingExtension: React.FC = ({ useEffect(() => { window.electron.getSettings().then((settings) => { - const saved = String(settings.commandHotkeys?.['system-supercmd-whisper-speak-toggle'] || 'Fn').trim(); - setWhisperHoldKey(saved || 'Fn'); + const saved = String(settings.commandHotkeys?.['system-supercmd-whisper-speak-toggle'] || DEFAULT_WHISPER_HOTKEY).trim(); + setWhisperHoldKey(saved || DEFAULT_WHISPER_HOTKEY); const savedLanguage = String(settings.ai?.speechLanguage || 'en-US').trim(); setSpeechLanguage(savedLanguage || 'en-US'); }).catch(() => {}); @@ -369,7 +371,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) => { @@ -467,13 +469,17 @@ const OnboardingExtension: React.FC = ({ if (status === 'denied' || status === 'restricted') { setPermissionNotes((prev) => ({ ...prev, - [id]: `${targetLabel} access is blocked. Enable SuperCmd in System Settings, then return.`, + [id]: isWindows + ? `${targetLabel} access is blocked. Open Settings → Privacy & Security → Microphone and enable SuperCmd, then return.` + : `${targetLabel} access is blocked. Enable SuperCmd in System Settings, then return.`, })); } else if (latestError) { if (/failed to request microphone access/i.test(latestError)) { setPermissionNotes((prev) => ({ ...prev, - [id]: 'Could not trigger the permission prompt. Open System Settings -> Privacy & Security, enable SuperCmd, then press request again.', + [id]: isWindows + ? 'Could not trigger the permission prompt. Open Settings → Privacy & Security → Microphone and enable SuperCmd, then press request again.' + : 'Could not trigger the permission prompt. Open System Settings -> Privacy & Security, enable SuperCmd, then press request again.', })); } else { setPermissionNotes((prev) => ({ ...prev, [id]: latestError })); @@ -488,13 +494,19 @@ const OnboardingExtension: React.FC = ({ if (id === 'microphone') { await new Promise((resolve) => setTimeout(resolve, 350)); } - const candidateUrls = id === 'microphone' - ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone'] - : id === 'speech-recognition' - ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_SpeechRecognition'] - : id === 'input-monitoring' - ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent'] - : [url]; + // On Windows, never open macOS x-apple.systempreferences URLs. + // Only open Windows Settings if access is explicitly blocked. + const candidateUrls = isWindows + ? (id === 'microphone' && !granted && (status === 'denied' || status === 'restricted') + ? ['ms-settings:privacy-microphone'] + : []) + : id === 'microphone' + ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone'] + : id === 'speech-recognition' + ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_SpeechRecognition'] + : id === 'input-monitoring' + ? [url, 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent'] + : [url]; let ok = false; for (const candidate of candidateUrls) { if (ok) break; @@ -520,20 +532,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 (
@@ -648,20 +670,20 @@ 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.

@@ -681,34 +703,45 @@ const OnboardingExtension: React.FC = ({ {shortcutStatus === 'error' ? Shortcut unavailable : null}
-

Click the hotkey field above to update your launcher shortcut.

- -
-
-

Replace Spotlight (Cmd + Space)

- +

Click the hotkey field above to update your launcher shortcut.

+ + {isWindows ? ( +
+

Want to use Alt+Space?

+
+

If another app (such as a launcher or the system window menu) is already bound to Alt+Space, Windows will route it there before SuperCmd can capture it.

+

Disable that app's Alt+Space binding first, then click the hotkey field here and press Alt+Space.

+

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

+
- {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 ? ( @@ -722,6 +755,55 @@ const OnboardingExtension: React.FC = ({ {step === 3 && (
+ {isWindows ? ( +
+
+

Permissions

+

+ No Accessibility or Input Monitoring setup needed — Windows handles those automatically. + The only permission required is Microphone access for Whisper dictation. +

+
+
+
+ +
+
+
+

Microphone

+ {openedPermissions['microphone'] ? ( + + Granted + + ) : ( + + )} +
+

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

+ {permissionNotes['microphone'] ? ( +

{permissionNotes['microphone']}

+ ) : null} +
+
+
+ ) : (
= ({ })}
+ )}
)} @@ -873,7 +956,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 + ? '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.'}

@@ -926,11 +1011,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/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index b62f281..47b0839 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/SuperCmdWhisper.tsx b/src/renderer/src/SuperCmdWhisper.tsx index a2cbf4e..f734582 100644 --- a/src/renderer/src/SuperCmdWhisper.tsx +++ b/src/renderer/src/SuperCmdWhisper.tsx @@ -12,9 +12,10 @@ interface SuperCmdWhisperProps { type WhisperState = 'idle' | 'listening' | 'processing' | 'error'; -// 'whisper' = OpenAI Whisper API (needs API key) -// 'native' = macOS SFSpeechRecognizer (no API key needed, like Chrome) -type WhisperBackend = 'whisper' | 'native'; +// 'whisper' = OpenAI Whisper API (needs API key) +// 'native' = macOS SFSpeechRecognizer (no API key needed) +// 'webspeech' = Chromium Web Speech API (Windows — uses Google's servers, no API key) +type WhisperBackend = 'whisper' | 'native' | 'webspeech'; type NativeFlushReason = 'timer' | 'silence' | 'final' | 'stop' | 'ended'; type NativeQueuedSuffix = { text: string; attempts: number; reason: NativeFlushReason }; @@ -212,6 +213,9 @@ const SuperCmdWhisper: React.FC = ({ const whisperStateRef = useRef('idle'); const startInFlightRef = useRef(false); + // Web Speech API backend refs (Windows) + const speechRecognitionRef = useRef(null); + // Native backend refs const nativeChunkDisposerRef = useRef<(() => void) | null>(null); const nativeProcessTimerRef = useRef(null); @@ -375,6 +379,11 @@ const SuperCmdWhisper: React.FC = ({ const canUseCloud = (wantsOpenAI && !!settings.ai.openaiApiKey) || (wantsElevenLabs && !!settings.ai.elevenlabsApiKey); + const isWindowsPlatform = window.electron?.platform === 'win32'; + // On Windows use the native System.Speech recognizer binary (compiled from + // src/native/speech-recognizer.cs) instead of the macOS SFSpeechRecognizer. + // The Web Speech API ('webspeech') requires Google API keys that Electron + // does not include, so it fails with network errors on non-Chrome builds. const backend: WhisperBackend = canUseCloud ? 'whisper' : 'native'; backendRef.current = backend; return { backend, language }; @@ -778,6 +787,10 @@ const SuperCmdWhisper: React.FC = ({ nativeChunkDisposerRef.current = null; } void window.electron.whisperStopNative().catch(() => {}); + if (speechRecognitionRef.current) { + try { speechRecognitionRef.current.abort(); } catch {} + speechRecognitionRef.current = null; + } stopVisualizer(); }, [stopNativeSilenceWatchdog, stopNativeProcessTimer, stopVisualizer]); @@ -822,7 +835,9 @@ const SuperCmdWhisper: React.FC = ({ setStatusText('Finishing whisper...'); try { const backend = backendRef.current; - const isNativeBackend = backend === 'native'; + // webspeech behaves like native: text is collected in combinedTranscriptRef + // and pasted at the end — no separate cloud upload. + const isNativeBackend = backend === 'native' || backend === 'webspeech'; if (backend === 'whisper') { // Stop periodic timer @@ -857,8 +872,18 @@ const SuperCmdWhisper: React.FC = ({ transcribeInFlightRef.current = false; } } + } else if (backend === 'webspeech') { + // Web Speech API — just stop the recognition; any pending onresult events + // fire synchronously before onend so the transcript is already captured. + if (speechRecognitionRef.current) { + try { speechRecognitionRef.current.stop(); } catch {} + speechRecognitionRef.current = null; + } + // Brief wait for any trailing onresult / onend callbacks to flush. + await new Promise((r) => setTimeout(r, 250)); + stopVisualizer(); } else { - // native backend — stop the native process + // native macOS backend — stop the native process stopNativeSilenceWatchdog(); stopNativeProcessTimer(); flushNativeCurrentPartial('stop'); @@ -1159,6 +1184,89 @@ const SuperCmdWhisper: React.FC = ({ recorder.start(500); restoreEditorFocusOnce(150); + } else if (backend === 'webspeech') { + stopRecording(); + // ── Chromium Web Speech API path (Windows) ──────────────── + // Uses the same Google speech service as Chrome, no API key required. + const SpeechRecognitionAPI = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognitionAPI) { + setState('error'); + whisperStateRef.current = 'error'; + setStatusText('Speech recognition unavailable.'); + setErrorText( + 'The Web Speech API is not available in this version. ' + + 'Please set an OpenAI API key in Settings → AI to use cloud transcription.' + ); + stopVisualizer(); + return; + } + + const recognition = new SpeechRecognitionAPI(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = sessionConfig.language; + speechRecognitionRef.current = recognition; + + recognition.onstart = () => { + if (requestSeq !== startRequestSeqRef.current || finalizingRef.current) return; + setState('listening'); + playRecordingCue('start'); + setStatusText('Listening... release shortcut to process.'); + window.electron.whisperDebugLog('start', 'Web Speech API started'); + }; + + recognition.onresult = (event: any) => { + if (requestSeq !== startRequestSeqRef.current || finalizingRef.current) return; + // Accumulate all results (final + interim) into the combined transcript. + let accumulated = ''; + for (let i = 0; i < event.results.length; i += 1) { + accumulated += event.results[i][0].transcript; + } + const normalized = normalizeTranscript(accumulated); + if (normalized) { + combinedTranscriptRef.current = normalized; + nativeCurrentPartialRef.current = normalized; + } + }; + + recognition.onerror = (event: any) => { + const code = String(event.error || ''); + // 'aborted' fires when we call recognition.stop() — not a real error. + if (code === 'aborted') return; + window.electron.whisperDebugLog('error', 'Web Speech API error', { error: code }); + if (finalizingRef.current) return; + setState('error'); + setStatusText('Speech recognition error.'); + setErrorText( + code === 'network' + ? 'No internet connection — speech recognition requires a network. Configure an OpenAI API key in Settings → AI for offline use.' + : `Speech recognition error: ${code}` + ); + stopVisualizer(); + }; + + recognition.onend = () => { + window.electron.whisperDebugLog('stop', 'Web Speech API ended'); + nativeProcessEndedRef.current = true; + // If we ended unexpectedly (not triggered by our stop()), finalize what we have. + if (!finalizingRef.current && combinedTranscriptRef.current) { + void finalizeAndClose(); + } + }; + + try { + recognition.start(); + restoreEditorFocusOnce(150); + } catch (err: any) { + setState('error'); + whisperStateRef.current = 'error'; + setStatusText('Speech recognition failed to start.'); + setErrorText(err?.message || 'Failed to start speech recognition.'); + stopVisualizer(); + return; + } + } else { stopRecording(); // ── Native macOS SFSpeechRecognizer path ───────────────── 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 0000000..6ce8f7b --- /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 0000000..4fadfef --- /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/src/renderer/src/components/ExtensionActionFooter.tsx b/src/renderer/src/components/ExtensionActionFooter.tsx index 08e6b28..02d2fef 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/main.tsx b/src/renderer/src/main.tsx index 9cff30d..e64d4a6 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -6,6 +6,11 @@ import ExtensionStoreApp from './ExtensionStoreApp'; import PromptApp from './PromptApp'; import '../styles/index.css'; +// Stamp the OS platform on so CSS can target [data-platform="win32"] etc. +if (window.electron?.platform) { + document.body.setAttribute('data-platform', window.electron.platform); +} + // Hash-based routing: launcher uses #/ , settings uses #/settings const hash = window.location.hash; const isSettings = hash.includes('/settings'); diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 4f2c6e0..e57c2b2 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; } diff --git a/src/renderer/src/settings/ExtensionsTab.tsx b/src/renderer/src/settings/ExtensionsTab.tsx index 4966fd7..d64f24f 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 12c5fad..92cb66d 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 { Keyboard, Info, RefreshCw, Download, RotateCcw, Type } from 'lucide-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; - try { - await window.electron.saveSettings({ fontSize: nextFontSize }); - } catch { - setSettings((prev) => (prev ? { ...prev, fontSize: previousFontSize } : prev)); - applyAppFontSize(previousFontSize); - } - }; + setSettings((prev) => (prev ? { ...prev, fontSize: nextFontSize } : prev)); + applyAppFontSize(nextFontSize); - const handleCheckForUpdates = async () => { + try { + await window.electron.saveSettings({ fontSize: nextFontSize }); + } catch { + setSettings((prev) => (prev ? { ...prev, fontSize: previousFontSize } : prev)); + applyAppFontSize(previousFontSize); + } + }, + [settings] + ); + + /** + * 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 = 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)}

)} @@ -305,15 +382,31 @@ const GeneralTab: React.FC = () => {
+ } + title="Launch at Login" + description="Automatically start SuperCmd when you log in." + > + + + } title="About" description="Version information." withBorder={false} > -

- SuperCmd v{currentVersion} -

+

SuperCmd v{currentVersion}

diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index 283c31b..68ee366 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -13,7 +13,7 @@ */ import React from 'react'; -import { Search, Power, Settings, Puzzle, Sparkles, Clipboard, FileText, Mic, Volume2, Brain, TerminalSquare } from 'lucide-react'; +import { Search, Power, Settings, Puzzle, Sparkles, Clipboard, FileText, Mic, Volume2, Brain, TerminalSquare, Pipette, Calculator, SunMoon, Coffee, Globe, Variable, Keyboard } from 'lucide-react'; import type { CommandInfo, EdgeTtsVoice } from '../../types/electron'; import supercmdLogo from '../../../../supercmd.svg'; import { formatShortcutForDisplay } from './hyper-key'; @@ -40,44 +40,105 @@ export type ReadVoiceOption = { /** * Filter and sort commands based on search query */ +/** + * Score a single query word against a target string. + * Returns 0 if the word does not match at all. + * + * Tiers (highest wins): + * 1000 exact + * 700 target starts with query + * 500 query appears at a word boundary in target ("color" in "Color Picker") + * 400 query appears anywhere in target ("olor" in "Color Picker") + * 350 acronym exact — "ev" matches "Environment Variables" + * 300 acronym prefix — "env" matches "Environment Variables" via initials "EV" + * 250 acronym contains + * 1–150 fuzzy subsequence — all chars of query appear in order, consecutive/word-start bonuses + */ +function scoreWord(query: string, target: string): number { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + if (!q || !t) return 0; + + // Tier 1: exact + if (t === q) return 1000; + + // Tier 2: starts-with + if (t.startsWith(q)) return 700; + + // Tier 3/4: substring — check for word-boundary bonus + const idx = t.indexOf(q); + if (idx >= 0) { + const atBoundary = idx === 0 || /[\s\-_/&.,()]/.test(t[idx - 1]); + return atBoundary ? 500 : 400; + } + + // Tier 5–7: acronym matching (first letters of each word) + const wordStarts = t + .split(/[\s\-_/&.,()]+/) + .filter(Boolean) + .map((w) => w[0] ?? ''); + const initials = wordStarts.join(''); + + if (initials === q) return 350; + if (initials.startsWith(q)) return 300; + if (initials.includes(q)) return 250; + + // Tier 8: fuzzy subsequence — all chars of query must appear in order + let qi = 0; + let lastIdx = -1; + let fuzzyScore = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + const isConsecutive = lastIdx === ti - 1; + const isWordStart = ti === 0 || /[\s\-_/&.,()]/.test(t[ti - 1]); + fuzzyScore += isWordStart ? 20 : isConsecutive ? 12 : 5; + lastIdx = ti; + qi++; + } + } + if (qi === q.length) return Math.min(fuzzyScore, 149); // cap below tier 7 + + return 0; +} + export function filterCommands(commands: CommandInfo[], query: string): CommandInfo[] { if (!query.trim()) { return commands; } - const lowerQuery = query.toLowerCase().trim(); + // Split into individual words so "dark mode" matches "Toggle Dark / Light Mode" + const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); const scored = commands .map((cmd) => { - const lowerTitle = cmd.title.toLowerCase(); - const lowerSubtitle = String(cmd.subtitle || '').toLowerCase(); - const keywords = cmd.keywords?.map((k) => k.toLowerCase()) || []; + const title = cmd.title; + const subtitle = String(cmd.subtitle || ''); + const keywords = cmd.keywords || []; let score = 0; - // Exact match - if (lowerTitle === lowerQuery) { - score = 200; - } - // Title starts with query - else if (lowerTitle.startsWith(lowerQuery)) { - score = 100; - } - // Title includes query - else if (lowerTitle.includes(lowerQuery)) { - score = 75; - } - // Keywords start with query - else if (keywords.some((k) => k.startsWith(lowerQuery))) { - score = 50; - } - // Keywords include query - else if (keywords.some((k) => k.includes(lowerQuery))) { - score = 25; - } - // Subtitle match - else if (lowerSubtitle.includes(lowerQuery)) { - score = 22; + if (words.length === 1) { + const q = words[0]; + const titleScore = scoreWord(q, title); + const keywordScore = keywords.reduce((best, k) => Math.max(best, scoreWord(q, k) * 0.85), 0); + const subtitleScore = scoreWord(q, subtitle) * 0.5; + score = Math.max(titleScore, keywordScore, subtitleScore); + } else { + // Every word must match somewhere (title or any keyword). + // Score = average of each word's best match; any word with 0 eliminates the command. + let total = 0; + let allMatch = true; + for (const word of words) { + const titleScore = scoreWord(word, title); + const keywordScore = keywords.reduce((best, k) => Math.max(best, scoreWord(word, k) * 0.85), 0); + const best = Math.max(titleScore, keywordScore); + if (best === 0) { + allMatch = false; + break; + } + total += best; + } + if (allMatch) score = total / words.length; } return { cmd, score }; @@ -350,6 +411,62 @@ export function getSystemCommandFallbackIcon(commandId: string): React.ReactNode ); } + if (commandId === 'system-color-picker') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-calculator') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-toggle-dark-mode') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-awake-toggle') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-hosts-editor') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-env-variables') { + return ( +
+ +
+ ); + } + + if (commandId === 'system-shortcut-guide') { + return ( +
+ +
+ ); + } + return (
diff --git a/src/renderer/src/utils/hyper-key.ts b/src/renderer/src/utils/hyper-key.ts index a832137..14f8092 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,14 +18,15 @@ 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 '↓'; - 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; }) diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 36493cc..10a1424 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -275,6 +275,67 @@ kbd { -webkit-app-region: no-drag; } +/* ── Windows overrides ────────────────────────────────────────────────────── + On Windows, backdrop-filter compositing causes a visible fade-in delay and + the semi-transparent rgba values look far more see-through than on macOS. + Override all glass surfaces to be fully opaque with no backdrop blur. */ +[data-platform="win32"] .glass-effect { + background: rgba(12, 12, 18, 0.97); + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +[data-platform="win32"] .cursor-prompt-surface { + background: rgba(12, 12, 18, 0.97); + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Actions / context-menu overlay panels rendered via inline styles get a + helper class so we can also lift their opacity on Windows. */ +[data-platform="win32"] .win-panel { + background: rgba(28, 28, 34, 0.99) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Fix ALL native