diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test diff --git a/scripts/bench-ax-bfs-queue.swift b/scripts/bench-ax-bfs-queue.swift new file mode 100644 index 00000000..585691e1 --- /dev/null +++ b/scripts/bench-ax-bfs-queue.swift @@ -0,0 +1,106 @@ +import Foundation + +private struct Node { + let firstChild: Int + let childCount: Int +} + +private let depthLimit = 8 +private let branching = 3 +private var nodes: [Node] = [] +private var frontier: [(index: Int, depth: Int)] = [(0, 0)] + +nodes.reserveCapacity(9_841) +nodes.append(Node(firstChild: -1, childCount: 0)) + +var frontierIndex = 0 +while frontierIndex < frontier.count { + let (nodeIndex, depth) = frontier[frontierIndex] + frontierIndex += 1 + guard depth < depthLimit else { continue } + + let firstChild = nodes.count + for _ in 0.. Int { + var queue = roots.map { ($0, 0) } + var inspected = 0 + var checksum = 0 + + while let (nodeIndex, depth) = queue.first { + queue.removeFirst() + inspected += 1 + if inspected > maxElements { break } + + checksum &+= nodeIndex &+ depth + if depth >= maxDepth { continue } + + let node = nodes[nodeIndex] + if node.childCount > 0 { + for offset in 0.. Int { + var queue = roots.map { ($0, 0) } + var queueIndex = 0 + var inspected = 0 + var checksum = 0 + + while queueIndex < queue.count { + let (nodeIndex, depth) = queue[queueIndex] + queueIndex += 1 + inspected += 1 + if inspected > maxElements { break } + + checksum &+= nodeIndex &+ depth + if depth >= maxDepth { continue } + + let node = nodes[nodeIndex] + if node.childCount > 0 { + for offset in 0.. Int) -> (label: String, ms: Double, checksum: Int) { + var checksum = 0 + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0.. { + await t.test('coalesces concurrent move requests into one follow-up run', async () => { + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(10); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + mover.requestMove(); + mover.requestMove(); + mover.requestMove(); + + await mover.whenIdle(); + + assert.deepEqual( + calls.map((args) => args[0]), + [ + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + ], + ); + assert.deepEqual(mover.getState(), { available: true, inFlight: false, queued: false }); + }); + + await t.test('marks missing aerospace binary unavailable and skips later requests', async () => { + let calls = 0; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async () => { + calls += 1; + const error = new Error('spawn aerospace ENOENT'); + error.code = 'ENOENT'; + throw error; + }, + }); + + mover.requestMove(); + await mover.whenIdle(); + mover.requestMove(); + await delay(0); + + assert.equal(calls, 1); + assert.deepEqual(mover.getState(), { available: false, inFlight: false, queued: false }); + }); + + await t.test('does not block the event loop while slow commands are in flight', async () => { + const slowCommandDelayMs = 80; + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(slowCommandDelayMs); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + const startedAt = performance.now(); + let zeroDelayTimerFiredAfterMs = Number.POSITIVE_INFINITY; + const timerPromise = new Promise((resolve) => { + setTimeout(() => { + zeroDelayTimerFiredAfterMs = performance.now() - startedAt; + resolve(); + }, 0); + }); + + mover.requestMove(); + await timerPromise; + await mover.whenIdle(); + + assert.ok(calls.length >= 1, 'first command should start'); + assert.ok( + zeroDelayTimerFiredAfterMs < slowCommandDelayMs, + `zero-delay timer fired after ${zeroDelayTimerFiredAfterMs}ms`, + ); + }); +}); diff --git a/scripts/test-auto-quit-manager.mjs b/scripts/test-auto-quit-manager.mjs new file mode 100644 index 00000000..a2127915 --- /dev/null +++ b/scripts/test-auto-quit-manager.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const { promisify } = require('util'); + +const autoQuitManagerPath = path.resolve('src/main/auto-quit-manager.ts'); +const metricsMode = process.argv.includes('--metrics'); + +function loadAutoQuitManager({ + frontmostBundleId = 'com.apple.finder', + recording = false, + musicPlaying = false, +} = {}) { + let now = 0; + let intervalCallback = null; + const counts = { + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + other: 0, + osascript: 0, + }; + const quits = []; + + function classifyAppleScript(args) { + const script = Array.isArray(args) ? args.join('\n') : ''; + if (script.includes('frontmost is true')) return 'frontmost'; + if (script.includes('AppleHDAEngineInput')) return 'recording'; + if (script.includes('player state is playing')) return 'music'; + if (script.includes('runningApplications()')) return 'quit'; + return 'other'; + } + + function execFile() { + throw new Error('execFile callback form is not supported by this test harness'); + } + + execFile[promisify.custom] = async (file, args) => { + if (file === '/usr/bin/osascript') { + counts.osascript += 1; + const kind = classifyAppleScript(args); + counts[kind] += 1; + if (kind === 'frontmost') return { stdout: `${frontmostBundleId}\n`, stderr: '' }; + if (kind === 'recording') return { stdout: recording ? '1\n' : '0\n', stderr: '' }; + if (kind === 'music') return { stdout: musicPlaying ? 'true\n' : 'false\n', stderr: '' }; + if (kind === 'quit') { + const script = args.at(-1) || ''; + const match = script.match(/if bid is "([^"]+)"/); + quits.push(match?.[1] || 'unknown'); + return { stdout: '', stderr: '' }; + } + } + return { stdout: '', stderr: '' }; + }; + + const source = fs.readFileSync(autoQuitManagerPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: autoQuitManagerPath, + }); + + const module = { exports: {} }; + const localRequire = (request) => { + if (request === 'child_process') return { execFile }; + return require(request); + }; + const testDate = { + now: () => now, + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date: testDate, + setInterval: (callback) => { + intervalCallback = callback; + return { callback }; + }, + clearInterval: (interval) => { + if (interval?.callback === intervalCallback) intervalCallback = null; + }, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: autoQuitManagerPath }); + + return { + counts, + quits, + module: module.exports, + advance(ms) { + now += ms; + }, + async tick() { + assert.equal(typeof intervalCallback, 'function', 'auto-quit interval should be running'); + await intervalCallback(); + }, + }; +} + +async function runNoTrackedAppDueScenario() { + const harness = loadAutoQuitManager(); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 60, + }, + ]); + + for (let i = 0; i < 3; i += 1) { + harness.advance(5000); + await harness.tick(); + } + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runOneNonMusicAppDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runNonMusicDueWithMusicTrackedButNotDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 60, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runDueAppIsFrontmostScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.example.editor' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runMusicAppDueAndPlayingScenario() { + const harness = loadAutoQuitManager({ + frontmostBundleId: 'com.apple.finder', + musicPlaying: true, + }); + harness.module.startAutoQuit([ + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +function compactCounts({ counts, quits }) { + return { + osascript: counts.osascript, + frontmost: counts.frontmost, + recording: counts.recording, + music: counts.music, + quit: counts.quit, + quitBundleIds: quits, + }; +} + +async function printMetrics() { + const metrics = { + noTrackedAppDueOver3Ticks: compactCounts(await runNoTrackedAppDueScenario()), + oneNonMusicAppDue: compactCounts(await runOneNonMusicAppDueScenario()), + }; + console.log(JSON.stringify(metrics, null, 2)); +} + +const tests = []; +function test(name, fn) { + tests.push({ name, fn }); +} + +test('skips AppleScript probes while no tracked app is due', async () => { + const result = await runNoTrackedAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 0, + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('only probes frontmost, recording, and quit for a due non-music app', async () => { + const result = await runOneNonMusicAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not check music when only non-music candidates are due', async () => { + const result = await runNonMusicDueWithMusicTrackedButNotDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not run recording or music probes when the only due app is frontmost', async () => { + const result = await runDueAppIsFrontmostScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 1, + frontmost: 1, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('checks music playback only when a due music app would otherwise quit', async () => { + const result = await runMusicAppDueAndPlayingScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 1, + quit: 0, + quitBundleIds: [], + }); +}); + +if (metricsMode) { + await printMetrics(); +} else { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + } +} diff --git a/scripts/test-camera-capture-single-encode.mjs b/scripts/test-camera-capture-single-encode.mjs new file mode 100644 index 00000000..3e493ccd --- /dev/null +++ b/scripts/test-camera-capture-single-encode.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cameraExtensionPath = path.join(root, 'src/renderer/src/CameraExtension.tsx'); +const cameraCapturePath = path.join(root, 'src/renderer/src/camera-capture.ts'); + +const { + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} = await importTs(cameraCapturePath); + +function makeMockCanvas(blob = new Blob(['fake-png'], { type: 'image/png' })) { + const calls = { + toBlob: [], + toDataURL: [], + }; + + return { + calls, + toBlob(callback, type) { + calls.toBlob.push(type); + callback(blob); + }, + toDataURL(type) { + calls.toDataURL.push(type); + return 'data:image/png;base64,legacy-preview'; + }, + }; +} + +function makeMockUrlApi() { + const created = []; + const revoked = []; + + return { + created, + revoked, + api: { + createObjectURL(blob) { + const objectUrl = `blob:supercmd-test-${created.length + 1}`; + created.push({ objectUrl, blob }); + return objectUrl; + }, + revokeObjectURL(objectUrl) { + revoked.push(objectUrl); + }, + }, + }; +} + +test('Camera capture single-encode path', async (t) => { + await t.test('mock canvas catches the legacy duplicate encode baseline', async () => { + const canvas = makeMockCanvas(); + + canvas.toDataURL('image/png'); + await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + + assert.deepEqual(canvas.calls.toDataURL, ['image/png']); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + }); + + await t.test('production capture encoding calls toBlob once and never toDataURL', async () => { + const expectedBlob = new Blob(['fake-png'], { type: 'image/png' }); + const canvas = makeMockCanvas(expectedBlob); + + const blob = await encodeCanvasAsPngBlob(canvas); + + assert.equal(blob, expectedBlob); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + assert.deepEqual(canvas.calls.toDataURL, []); + }); + + await t.test('capture preview is shown from an object URL', () => { + const { api, created, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + const blob = new Blob(['preview'], { type: 'image/png' }); + + const preview = createCapturePreview(blob, manager); + + assert.deepEqual(preview, { url: 'blob:supercmd-test-1', visible: true }); + assert.equal(created.length, 1); + assert.equal(created[0].blob, blob); + assert.deepEqual(revoked, []); + }); + + await t.test('object URL is revoked when preview is cleared', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + assert.equal(manager.getCurrentUrl(), 'blob:supercmd-test-1'); + + manager.clear(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('object URL is revoked when preview manager is disposed on unmount', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + manager.dispose(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('component wires the object URL preview to clear and unmount paths', () => { + const source = fs.readFileSync(cameraExtensionPath, 'utf8'); + + assert.ok(source.includes('const captureBlob = await encodeCanvasAsPngBlob(canvas);')); + assert.ok(source.includes("from './camera-capture';")); + assert.ok(!source.includes('.toDataURL(')); + assert.ok(source.includes('src={capturePreviewUrl}')); + assert.ok(source.includes('clearCapturePreview();')); + assert.ok(source.includes('capturePreviewUrlManagerRef.current?.dispose();')); + }); +}); diff --git a/scripts/test-clipboard-history-persistence.mjs b/scripts/test-clipboard-history-persistence.mjs new file mode 100644 index 00000000..eb1f0bcc --- /dev/null +++ b/scripts/test-clipboard-history-persistence.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; + +const clipboardManagerPath = 'src/main/clipboard-manager.ts'; +const mainPath = 'src/main/main.ts'; + +function makeTextItem(index) { + return { + id: `item-${index}`, + type: 'text', + content: `Clipboard text ${index}`, + preview: `Clipboard text ${index}`, + timestamp: Date.now() + index, + pinned: false, + }; +} + +function assertIncludes(source, needle) { + assert.ok(source.includes(needle), `Source should include: ${needle}`); +} + +function assertNotIncludes(source, needle) { + assert.ok(!source.includes(needle), `Source should not include: ${needle}`); +} + +function extractFunction(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `Missing function signature: ${signature}`); + + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `Missing function body: ${signature}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + + assert.fail(`Unterminated function body: ${signature}`); +} + +function measureCoalescedRapidAdditions(additions) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-clipboard-after-')); + const historyPath = path.join(tempDir, 'history.json'); + const tempPath = `${historyPath}.tmp`; + const history = []; + let serializeMs = 0; + let writeMs = 0; + + try { + for (let index = 0; index < additions; index += 1) { + history.unshift(makeTextItem(index)); + } + + const serializeStart = performance.now(); + const json = JSON.stringify(history, null, 2); + serializeMs += performance.now() - serializeStart; + + const writeStart = performance.now(); + fs.writeFileSync(tempPath, json); + fs.renameSync(tempPath, historyPath); + writeMs += performance.now() - writeStart; + + return { + additions, + writeCount: 1, + serializeMs, + writeMs, + finalBytes: fs.statSync(historyPath).size, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('clipboard history persistence coalesces rapid additions into async atomic writes', () => { + const source = fs.readFileSync(clipboardManagerPath, 'utf8'); + const syncHistoryWriteSites = (source.match(/fs\.writeFileSync\(historyPath/g) || []).length; + const metrics = measureCoalescedRapidAdditions(100); + + console.log( + `[clipboard-after] rapidAdditions=${metrics.additions} ` + + `coalescedWrites=${metrics.writeCount} ` + + `serializeMs=${metrics.serializeMs.toFixed(2)} ` + + `writeMs=${metrics.writeMs.toFixed(2)} ` + + `blockingMs=${(metrics.serializeMs + metrics.writeMs).toFixed(2)} ` + + `finalBytes=${metrics.finalBytes}` + ); + + assert.equal(syncHistoryWriteSites, 0); + assertIncludes(source, 'const HISTORY_SAVE_DEBOUNCE_MS = 250;'); + assertIncludes(source, 'historySaveTimer = setTimeout'); + assertIncludes(source, 'let historySaveDirty = false;'); + assertIncludes(source, 'async function writeHistoryFileAtomic(serializedHistory: string): Promise'); + assertIncludes(source, "await fsp.writeFile(tempPath, serializedHistory, 'utf-8');"); + assertIncludes(source, 'await fsp.rename(tempPath, historyPath);'); + assertIncludes(source, 'while (historySaveDirty)'); + assertIncludes(source, 'void flushClipboardHistoryWrites();'); + assert.equal(metrics.writeCount, 1); +}); + +test('clipboard history persistence flushes on stop, clear, delete, and app quit', () => { + const clipboardSource = fs.readFileSync(clipboardManagerPath, 'utf8'); + const mainSource = fs.readFileSync(mainPath, 'utf8'); + + const stopBlock = extractFunction(clipboardSource, 'export async function stopClipboardMonitor(): Promise'); + const clearBlock = extractFunction(clipboardSource, 'export async function clearClipboardHistory(): Promise'); + const deleteBlock = extractFunction(clipboardSource, 'export async function deleteClipboardItem(id: string): Promise'); + const beforeQuitBlock = extractFunction(mainSource, "app.on('before-quit', (event: any) =>"); + + assertIncludes(stopBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(clearBlock, 'saveHistory({ flush: true });'); + assertIncludes(clearBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(deleteBlock, 'saveHistory({ flush: true });'); + assertIncludes(deleteBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(beforeQuitBlock, 'hasPendingClipboardHistoryWrites()'); + assertIncludes(beforeQuitBlock, 'const shouldBypassAsyncStorageQuitFlush = updateRestartInProgress;'); + assertIncludes(beforeQuitBlock, 'const shouldFlushNotes = !shouldBypassAsyncStorageQuitFlush && hasPendingNotesSave();'); + assertIncludes(beforeQuitBlock, 'const shouldFlushClipboard = !shouldBypassAsyncStorageQuitFlush && hasPendingClipboardHistoryWrites();'); + assertIncludes(beforeQuitBlock, 'event.preventDefault();'); + assertIncludes(beforeQuitBlock, 'flushClipboardHistoryWrites()'); + assertIncludes(mainSource, 'await flushClipboardHistoryWrites();'); + assertIncludes(mainSource, "ipcMain.handle('clipboard-clear-history', async () =>"); + assertIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) =>"); + assertNotIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', (_event: any, id: string) =>"); +}); diff --git a/scripts/test-notes-async-saves.mjs b/scripts/test-notes-async-saves.mjs new file mode 100644 index 00000000..c3dc60c5 --- /dev/null +++ b/scripts/test-notes-async-saves.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import Module, { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { transform } from 'esbuild'; + +const require = createRequire(import.meta.url); +const repoRoot = process.cwd(); +const notesStorePath = path.join(repoRoot, 'src/main/notes-store.ts'); + +function createMetrics() { + return { + notesJsonWriteCount: 0, + syncWriteFileCount: 0, + asyncWriteFileCount: 0, + syncWriteMs: 0, + asyncWriteMs: 0, + }; +} + +function resetMetrics(metrics) { + metrics.notesJsonWriteCount = 0; + metrics.syncWriteFileCount = 0; + metrics.asyncWriteFileCount = 0; + metrics.syncWriteMs = 0; + metrics.asyncWriteMs = 0; +} + +function isNotesJsonPath(filePath) { + const normalizedPath = String(filePath); + return ( + path.basename(path.dirname(normalizedPath)) === 'notes' && + (path.basename(normalizedPath) === 'notes.json' || path.basename(normalizedPath).startsWith('notes.json.')) + ); +} + +function blockForMs(durationMs) { + const end = performance.now() + durationMs; + while (performance.now() < end) { + // Deliberately burn a tiny amount of CPU to make sync write blocking visible. + } +} + +async function loadNotesStore({ writeDelayMs = 0 } = {}) { + const testRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'supercmd-notes-store-')); + const userData = path.join(testRoot, 'user-data'); + const compiledPath = path.join(testRoot, 'notes-store.cjs'); + + const source = fs.readFileSync(notesStorePath, 'utf8'); + assert.match(source, /flushNotesToDisk\(\) to persist queued edits/); + assert.match(source, /const NOTES_SAVE_DEBOUNCE_MS = 250;/); + assert.match(source, /notesSaveTimer = setTimeout/); + const { code } = await transform(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', + }); + await fsp.writeFile(compiledPath, code, 'utf8'); + + const realFs = require('node:fs'); + const realFsPromises = require('node:fs/promises'); + const originalWriteFileSync = realFs.writeFileSync; + const originalFsPromisesWriteFile = realFs.promises.writeFile; + const originalStandaloneWriteFile = realFsPromises.writeFile; + const originalModuleLoad = Module._load; + const metrics = createMetrics(); + + function recordWrite(filePath, kind, elapsedMs) { + if (!isNotesJsonPath(filePath)) return; + metrics.notesJsonWriteCount += 1; + if (kind === 'sync') { + metrics.syncWriteFileCount += 1; + metrics.syncWriteMs += elapsedMs; + } else { + metrics.asyncWriteFileCount += 1; + metrics.asyncWriteMs += elapsedMs; + } + } + + realFs.writeFileSync = function patchedWriteFileSync(filePath, ...args) { + const isNotesWrite = isNotesJsonPath(filePath); + const startedAt = performance.now(); + if (isNotesWrite && writeDelayMs > 0) { + blockForMs(writeDelayMs); + } + try { + return originalWriteFileSync.apply(this, [filePath, ...args]); + } finally { + recordWrite(filePath, 'sync', performance.now() - startedAt); + } + }; + + async function patchedAsyncWriteFile(filePath, ...args) { + const startedAt = performance.now(); + try { + return await originalStandaloneWriteFile.apply(this, [filePath, ...args]); + } finally { + recordWrite(filePath, 'async', performance.now() - startedAt); + } + } + + realFs.promises.writeFile = patchedAsyncWriteFile; + + const fsPromisesStub = { + ...realFsPromises, + writeFile: patchedAsyncWriteFile, + }; + + Module._load = function patchedModuleLoad(request, parent, isMain) { + if (request === 'electron') { + return { + app: { + getPath(name) { + assert.equal(name, 'userData'); + return userData; + }, + }, + clipboard: { writeText() {} }, + dialog: { + async showSaveDialog() { + return { canceled: true }; + }, + async showOpenDialog() { + return { canceled: true, filePaths: [] }; + }, + }, + BrowserWindow: class BrowserWindow {}, + }; + } + if (request === 'fs/promises' || request === 'node:fs/promises') { + return fsPromisesStub; + } + return originalModuleLoad.apply(this, [request, parent, isMain]); + }; + + try { + const store = require(compiledPath); + return { + store, + metrics, + resetMetrics: () => resetMetrics(metrics), + getNotesFilePath: () => path.join(userData, 'notes', 'notes.json'), + async cleanup() { + Module._load = originalModuleLoad; + realFs.writeFileSync = originalWriteFileSync; + realFs.promises.writeFile = originalFsPromisesWriteFile; + realFsPromises.writeFile = originalStandaloneWriteFile; + delete require.cache[compiledPath]; + await fsp.rm(testRoot, { recursive: true, force: true }); + }, + }; + } catch (error) { + Module._load = originalModuleLoad; + realFs.writeFileSync = originalWriteFileSync; + realFs.promises.writeFile = originalFsPromisesWriteFile; + realFsPromises.writeFile = originalStandaloneWriteFile; + await fsp.rm(testRoot, { recursive: true, force: true }); + throw error; + } +} + +async function flushStore(store) { + assert.equal( + typeof store.flushNotesToDisk, + 'function', + 'notes store must export flushNotesToDisk() so app quit can persist queued saves' + ); + await store.flushNotesToDisk(); +} + +async function readPersistedNotes(notesFilePath) { + return JSON.parse(await fsp.readFile(notesFilePath, 'utf8')); +} + +test('Notes autosave persistence coalesces repeated updates off the synchronous path', async () => { + const harness = await loadNotesStore({ writeDelayMs: 1 }); + try { + harness.store.initNoteStore(); + const note = harness.store.createNote({ title: 'Autosave baseline', content: 'initial' }); + if (typeof harness.store.flushNotesToDisk === 'function') { + await harness.store.flushNotesToDisk(); + } + + harness.resetMetrics(); + const startedAt = performance.now(); + for (let index = 0; index < 20; index += 1) { + const updated = harness.store.updateNote(note.id, { + title: `Autosave ${index}`, + content: `body ${index}`, + }); + assert.equal(updated?.content, `body ${index}`); + } + const loopMs = performance.now() - startedAt; + + assert.equal(harness.store.getNoteById(note.id)?.content, 'body 19'); + if (typeof harness.store.flushNotesToDisk !== 'function') { + console.log( + `[Notes autosave baseline] repeatedUpdates=20 writes=${harness.metrics.notesJsonWriteCount} ` + + `syncWrites=${harness.metrics.syncWriteFileCount} asyncWrites=${harness.metrics.asyncWriteFileCount} ` + + `loopMs=${loopMs.toFixed(2)} syncWriteMs=${harness.metrics.syncWriteMs.toFixed(2)}` + ); + } + await flushStore(harness.store); + const persisted = await readPersistedNotes(harness.getNotesFilePath()); + assert.equal(persisted[0]?.content, 'body 19'); + + console.log( + `[Notes autosave metrics] repeatedUpdates=20 writes=${harness.metrics.notesJsonWriteCount} ` + + `syncWrites=${harness.metrics.syncWriteFileCount} asyncWrites=${harness.metrics.asyncWriteFileCount} ` + + `loopMs=${loopMs.toFixed(2)} syncWriteMs=${harness.metrics.syncWriteMs.toFixed(2)}` + ); + + assert.equal(harness.metrics.syncWriteFileCount, 0); + assert.ok( + harness.metrics.notesJsonWriteCount <= 1, + `expected repeated autosaves to coalesce to <= 1 physical write, got ${harness.metrics.notesJsonWriteCount}` + ); + } finally { + await harness.cleanup(); + } +}); + +test('Notes flush persists the latest queued state before shutdown', async () => { + const harness = await loadNotesStore(); + try { + harness.store.initNoteStore(); + const note = harness.store.createNote({ title: 'Flush baseline', content: 'initial' }); + await flushStore(harness.store); + + harness.resetMetrics(); + harness.store.updateNote(note.id, { content: 'queued one' }); + harness.store.updateNote(note.id, { content: 'queued two' }); + + assert.equal(harness.store.getNoteById(note.id)?.content, 'queued two'); + await flushStore(harness.store); + + const persisted = await readPersistedNotes(harness.getNotesFilePath()); + assert.equal(persisted[0]?.content, 'queued two'); + assert.equal(harness.metrics.syncWriteFileCount, 0); + assert.ok( + harness.metrics.notesJsonWriteCount <= 1, + `expected flush to persist the latest state once, got ${harness.metrics.notesJsonWriteCount} writes` + ); + } finally { + await harness.cleanup(); + } +}); diff --git a/scripts/test-speak-settings-listener.mjs b/scripts/test-speak-settings-listener.mjs new file mode 100644 index 00000000..d67c4011 --- /dev/null +++ b/scripts/test-speak-settings-listener.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const hookSource = fs.readFileSync('src/renderer/src/hooks/useSpeakManager.ts', 'utf8'); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Promise, + String, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function makeRecorder(currentVoice = 'en-US-EricNeural') { + const calls = []; + const configuredModels = []; + const configuredEdgeVoices = []; + const appliedOptions = []; + + return { + calls, + configuredModels, + configuredEdgeVoices, + appliedOptions, + options: { + getCurrentVoice: () => currentVoice, + setConfiguredTtsModel: (value) => configuredModels.push(value), + setConfiguredEdgeTtsVoice: (value) => configuredEdgeVoices.push(value), + updateSpeakOptions: async (patch) => { + calls.push(patch); + currentVoice = patch.voice; + return { voice: patch.voice, rate: '+0%' }; + }, + setSpeakOptions: (next) => { + appliedOptions.push(next); + }, + }, + }; +} + +const { + applySpeakSettings, + buildElevenLabsSpeakModel, + parseElevenLabsSpeakModel, + resolveSpeakSettings, +} = loadTsModule('src/renderer/src/utils/speak-settings-sync.ts'); + +function assertJsonEqual(actual, expected) { + assert.equal(JSON.stringify(actual), JSON.stringify(expected)); +} + +test('speak settings sync uses initial load and settings-updated listener, not polling', () => { + assert.match(hookSource, /window\.electron\.getSettings\(\)\.then\(syncFromSettings\)/); + assert.match(hookSource, /window\.electron\.onSettingsUpdated\?\.\(\(settings\) =>/); + assert.match(hookSource, /cleanupSettings\?\.\(\)/); + assert.doesNotMatch(hookSource, /setInterval\(\s*syncFromSettings/); + assert.doesNotMatch(hookSource, /clearInterval\(/); +}); + +test('initial settings load applies configured Edge TTS voice once', async () => { + const recorder = makeRecorder('en-US-EricNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: 'edge-tts', + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assert.deepEqual(recorder.configuredModels, ['edge-tts']); + assert.deepEqual(recorder.configuredEdgeVoices, ['en-US-GuyNeural']); + assertJsonEqual(recorder.calls, [{ voice: 'en-US-GuyNeural', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'en-US-GuyNeural', rate: '+0%' }]); +}); + +test('live settings update switches to ElevenLabs voice without extra idle updates', async () => { + const recorder = makeRecorder('en-US-GuyNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assertJsonEqual(recorder.calls, [{ voice: 'AZnzlk1XvdvUeBnXmlld', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'AZnzlk1XvdvUeBnXmlld', rate: '+0%' }]); +}); + +test('ElevenLabs model parser and resolver preserve model and default voice behavior', () => { + assertJsonEqual(parseElevenLabsSpeakModel('elevenlabs-turbo-v2@pNInz6obpgDQGcFmaJgB'), { + model: 'elevenlabs-turbo-v2', + voiceId: 'pNInz6obpgDQGcFmaJgB', + }); + assert.equal(resolveSpeakSettings({ ai: { textToSpeechModel: 'elevenlabs-multilingual-v2' } }).targetVoice, '21m00Tcm4TlvDq8ikWAM'); +}); diff --git a/src/main/aerospace-workspace.ts b/src/main/aerospace-workspace.ts new file mode 100644 index 00000000..5aaf1a17 --- /dev/null +++ b/src/main/aerospace-workspace.ts @@ -0,0 +1,146 @@ +import { execFile } from 'child_process'; + +const DEFAULT_AEROSPACE_TIMEOUT_MS = 500; +const DEFAULT_SUPERCMD_BUNDLE_ID = 'com.supercmd.app'; + +export type AerospaceCommandRunner = (args: string[]) => Promise; + +export type AerospaceWorkspaceMoverOptions = { + platform?: NodeJS.Platform; + bundleId?: string; + timeoutMs?: number; + shouldRun?: () => boolean; + runCommand?: AerospaceCommandRunner; +}; + +export type AerospaceWorkspaceMoverState = { + available: boolean | null; + inFlight: boolean; + queued: boolean; +}; + +export type AerospaceWorkspaceMover = { + requestMove: () => void; + whenIdle: () => Promise; + getState: () => AerospaceWorkspaceMoverState; +}; + +function isAerospaceUnavailableError(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === 'ENOENT'; +} + +export function execAerospaceCommand(args: string[], timeoutMs = DEFAULT_AEROSPACE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + execFile( + 'aerospace', + args, + { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + timeout: timeoutMs, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(String(stdout || '')); + }, + ); + }); +} + +export function createAerospaceWorkspaceMover(options: AerospaceWorkspaceMoverOptions = {}): AerospaceWorkspaceMover { + const platform = options.platform ?? process.platform; + const bundleId = options.bundleId ?? DEFAULT_SUPERCMD_BUNDLE_ID; + const timeoutMs = options.timeoutMs ?? DEFAULT_AEROSPACE_TIMEOUT_MS; + const runCommand = options.runCommand ?? ((args) => execAerospaceCommand(args, timeoutMs)); + + let available: boolean | null = null; + let inFlight = false; + let queued = false; + let inFlightPromise: Promise | null = null; + + function canAttemptMove(): boolean { + if (available === false || platform !== 'darwin') return false; + try { + return options.shouldRun ? options.shouldRun() : true; + } catch { + return false; + } + } + + async function reconcileOnce(): Promise { + if (!canAttemptMove()) return; + + try { + const focusedWs = String(await runCommand(['list-workspaces', '--focused'])).trim(); + if (!focusedWs) return; + available = true; + + const windowsRaw = String( + await runCommand([ + 'list-windows', + '--all', + '--app-bundle-id', + bundleId, + '--format', + '%{window-id} %{workspace}', + ]), + ).trim(); + if (!windowsRaw) return; + + for (const line of windowsRaw.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) continue; + const [windowId, currentWs] = parts; + if (currentWs === focusedWs) continue; + await runCommand(['move-node-to-workspace', focusedWs, '--window-id', windowId]); + } + } catch (error) { + // ENOENT means the binary is unavailable; skip future attempts. + if (isAerospaceUnavailableError(error)) { + available = false; + } + // Other failures are transient (server not running, timeout, bad output). + } + } + + async function runQueuedReconciliations(): Promise { + try { + do { + queued = false; + await reconcileOnce(); + } while (queued && canAttemptMove()); + } finally { + inFlight = false; + inFlightPromise = null; + } + } + + function requestMove(): void { + if (!canAttemptMove()) return; + if (inFlight) { + queued = true; + return; + } + + inFlight = true; + inFlightPromise = runQueuedReconciliations(); + void inFlightPromise.catch(() => {}); + } + + function whenIdle(): Promise { + return inFlightPromise ?? Promise.resolve(); + } + + function getState(): AerospaceWorkspaceMoverState { + return { available, inFlight, queued }; + } + + return { + requestMove, + whenIdle, + getState, + }; +} diff --git a/src/main/auto-quit-manager.ts b/src/main/auto-quit-manager.ts index d79f98a3..76fbdc30 100644 --- a/src/main/auto-quit-manager.ts +++ b/src/main/auto-quit-manager.ts @@ -37,6 +37,8 @@ const MUSIC_BUNDLE_IDS = new Set([ let pollInterval: ReturnType | null = null; let lastFrontmostAt = new Map(); // bundleId → timestamp let autoQuitApps: AutoQuitAppEntry[] = []; +let trackedBundleIds = new Set(); +let trackedMusicBundleIds = new Set(); let checking = false; /** @@ -134,6 +136,61 @@ async function isMusicPlaying(): Promise { } } +function rebuildTrackedBundleIds(): void { + trackedBundleIds = new Set(); + trackedMusicBundleIds = new Set(); + for (const entry of autoQuitApps) { + trackedBundleIds.add(entry.bundleId); + if (MUSIC_BUNDLE_IDS.has(entry.bundleId)) { + trackedMusicBundleIds.add(entry.bundleId); + } + } +} + +function setAutoQuitApps(apps: AutoQuitAppEntry[]): void { + autoQuitApps = apps; + rebuildTrackedBundleIds(); +} + +function seedMissingLastFrontmostTimestamps(now: number): void { + for (const app of autoQuitApps) { + if (!lastFrontmostAt.has(app.bundleId)) { + lastFrontmostAt.set(app.bundleId, now); + } + } +} + +function getDueAutoQuitCandidates(now: number): AutoQuitAppEntry[] { + const dueCandidates: AutoQuitAppEntry[] = []; + + for (const entry of autoQuitApps) { + if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + + const lastActive = lastFrontmostAt.get(entry.bundleId); + if (lastActive === undefined) { + // First time seeing this app; record now as the inactivity baseline. + lastFrontmostAt.set(entry.bundleId, now); + continue; + } + + const inactiveMs = now - lastActive; + const timeoutMs = entry.timeoutSeconds * 1000; + if (inactiveMs >= timeoutMs) { + dueCandidates.push(entry); + } + } + + return dueCandidates; +} + +function pruneLastFrontmostEntries(frontmostBundleId: string | null): void { + for (const bundleId of lastFrontmostAt.keys()) { + if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { + lastFrontmostAt.delete(bundleId); + } + } +} + /** * Check all tracked apps and quit those that exceeded their timeout */ @@ -142,56 +199,48 @@ async function checkAndQuit(): Promise { if (autoQuitApps.length === 0) return; checking = true; try { - // Pause all auto-quit if system is recording - const recording = await isSystemRecording(); - if (recording) return; + const now = Date.now(); + const dueCandidates = getDueAutoQuitCandidates(now); + if (dueCandidates.length === 0) { + pruneLastFrontmostEntries(null); + return; + } const frontmostBundleId = await getFrontmostBundleId(); if (!frontmostBundleId) return; - // Check if music is playing (protect music apps) - const musicPlaying = await isMusicPlaying(); - - const now = Date.now(); - // Update frontmost timestamp lastFrontmostAt.set(frontmostBundleId, now); - // Check each auto-quit app - for (const entry of autoQuitApps) { - // Skip if this app is currently frontmost - if (entry.bundleId === frontmostBundleId) continue; + const nonFrontmostDueCandidates = dueCandidates.filter( + (entry) => entry.bundleId !== frontmostBundleId + ); + if (nonFrontmostDueCandidates.length === 0) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip protected apps - if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + // Pause auto-quit only when a non-frontmost tracked app is actually due. + const recording = await isSystemRecording(); + if (recording) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip music apps if music is playing + const hasDueMusicApp = nonFrontmostDueCandidates.some((entry) => + trackedMusicBundleIds.has(entry.bundleId) + ); + const musicPlaying = hasDueMusicApp ? await isMusicPlaying() : false; + + for (const entry of nonFrontmostDueCandidates) { if (musicPlaying && MUSIC_BUNDLE_IDS.has(entry.bundleId)) continue; - const lastActive = lastFrontmostAt.get(entry.bundleId); - if (lastActive === undefined) { - // First time seeing this app — record now as baseline - lastFrontmostAt.set(entry.bundleId, now); - continue; - } - - const inactiveMs = now - lastActive; - const timeoutMs = entry.timeoutSeconds * 1000; - - if (inactiveMs >= timeoutMs) { - await quitApp(entry.bundleId); - // Remove from tracking so we don't try to quit again - lastFrontmostAt.delete(entry.bundleId); - } + await quitApp(entry.bundleId); + // Remove from tracking so we don't try to quit again + lastFrontmostAt.delete(entry.bundleId); } - // Prune lastFrontmostAt entries for apps no longer tracked or frontmost - const trackedBundleIds = new Set(autoQuitApps.map((e) => e.bundleId)); - for (const bundleId of lastFrontmostAt.keys()) { - if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { - lastFrontmostAt.delete(bundleId); - } - } + pruneLastFrontmostEntries(frontmostBundleId); } finally { checking = false; } @@ -201,18 +250,13 @@ async function checkAndQuit(): Promise { * Start the auto-quit polling loop */ export function startAutoQuit(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; - if (pollInterval) return; // Already running + setAutoQuitApps(apps); if (apps.length === 0) return; // Initialize all tracked apps with current time - const now = Date.now(); - for (const app of apps) { - if (!lastFrontmostAt.has(app.bundleId)) { - lastFrontmostAt.set(app.bundleId, now); - } - } + seedMissingLastFrontmostTimestamps(Date.now()); + if (pollInterval) return; // Already running pollInterval = setInterval(checkAndQuit, 5000); } @@ -230,12 +274,14 @@ export function stopAutoQuit(): void { * Update the app list (restarts polling if needed) */ export function updateAutoQuitApps(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; + setAutoQuitApps(apps); if (apps.length === 0) { stopAutoQuit(); lastFrontmostAt.clear(); } else if (!pollInterval) { startAutoQuit(apps); + } else { + seedMissingLastFrontmostTimestamps(Date.now()); } } @@ -251,6 +297,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { } else { autoQuitApps.push(entry); } + rebuildTrackedBundleIds(); // Set baseline timestamp lastFrontmostAt.set(entry.bundleId, Date.now()); if (!pollInterval) { @@ -262,7 +309,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { * Remove an app from auto-quit */ export function removeAutoQuitApp(bundleId: string): void { - autoQuitApps = autoQuitApps.filter(a => a.bundleId !== bundleId); + setAutoQuitApps(autoQuitApps.filter(a => a.bundleId !== bundleId)); lastFrontmostAt.delete(bundleId); if (autoQuitApps.length === 0) { stopAutoQuit(); diff --git a/src/main/clipboard-manager.ts b/src/main/clipboard-manager.ts index fa1453ff..d820737b 100644 --- a/src/main/clipboard-manager.ts +++ b/src/main/clipboard-manager.ts @@ -11,6 +11,7 @@ import { app, clipboard, nativeImage } from 'electron'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -87,6 +88,9 @@ export interface ClipboardItem { const MAX_ITEMS = 1000; const POLL_INTERVAL = 1000; // 1 second +// History writes are intentionally debounced off the clipboard poll path; +// graceful quit and destructive history actions flush queued writes. +const HISTORY_SAVE_DEBOUNCE_MS = 250; const MAX_TEXT_LENGTH = 100_000; // Don't store huge text items const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB max per image const INTERNAL_CLIPBOARD_PROBE_REGEX = /^__supercmd_[a-z0-9_]+_probe__\d+_[a-z0-9]+$/i; @@ -174,6 +178,11 @@ function ensurePinnedOrder(): void { // ─── Persistence ──────────────────────────────────────────────────── +let historySaveTimer: NodeJS.Timeout | null = null; +let historySaveDirty = false; +let historySaveFlushPromise: Promise | null = null; +let historyWriteInFlight: Promise | null = null; + function loadHistory(): void { try { const historyPath = getHistoryFilePath(); @@ -215,13 +224,96 @@ function loadHistory(): void { } } -function saveHistory(): void { +function getHistoryTempFilePath(historyPath: string): string { + return `${historyPath}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`; +} + +async function writeHistoryFileAtomic(serializedHistory: string): Promise { + const historyPath = getHistoryFilePath(); + const tempPath = getHistoryTempFilePath(historyPath); + try { - const historyPath = getHistoryFilePath(); - fs.writeFileSync(historyPath, JSON.stringify(clipboardHistory, null, 2)); + await fsp.mkdir(path.dirname(historyPath), { recursive: true }); + await fsp.writeFile(tempPath, serializedHistory, 'utf-8'); + await fsp.rename(tempPath, historyPath); } catch (e) { - console.error('Failed to save clipboard history:', e); + try { + await fsp.unlink(tempPath); + } catch {} + throw e; + } +} + +function clearHistorySaveTimer(): void { + if (historySaveTimer) { + clearTimeout(historySaveTimer); + historySaveTimer = null; + } +} + +async function drainHistorySaveQueue(): Promise { + clearHistorySaveTimer(); + + if (historyWriteInFlight) { + await historyWriteInFlight; + } + + while (historySaveDirty) { + clearHistorySaveTimer(); + historySaveDirty = false; + const serializedHistory = JSON.stringify(clipboardHistory, null, 2); + const writePromise = writeHistoryFileAtomic(serializedHistory); + historyWriteInFlight = writePromise; + try { + await writePromise; + } catch (e) { + historySaveDirty = true; + throw e; + } finally { + if (historyWriteInFlight === writePromise) { + historyWriteInFlight = null; + } + } + } +} + +export function hasPendingClipboardHistoryWrites(): boolean { + return Boolean(historySaveTimer || historySaveDirty || historyWriteInFlight || historySaveFlushPromise); +} + +export function flushClipboardHistoryWrites(): Promise { + clearHistorySaveTimer(); + + if (!hasPendingClipboardHistoryWrites()) { + return Promise.resolve(); } + + if (!historySaveFlushPromise) { + historySaveFlushPromise = drainHistorySaveQueue() + .catch((e) => { + console.error('Failed to save clipboard history:', e); + }) + .finally(() => { + historySaveFlushPromise = null; + }); + } + + return historySaveFlushPromise; +} + +function saveHistory(options: { flush?: boolean } = {}): void { + historySaveDirty = true; + + if (options.flush) { + void flushClipboardHistoryWrites(); + return; + } + + clearHistorySaveTimer(); + historySaveTimer = setTimeout(() => { + historySaveTimer = null; + void flushClipboardHistoryWrites(); + }, HISTORY_SAVE_DEBOUNCE_MS); } // ─── Clipboard Monitoring ─────────────────────────────────────────── @@ -888,11 +980,12 @@ export function startClipboardMonitor(): void { console.log('Clipboard monitor started'); } -export function stopClipboardMonitor(): void { +export async function stopClipboardMonitor(): Promise { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + await flushClipboardHistoryWrites(); console.log('Clipboard monitor stopped'); } @@ -929,13 +1022,13 @@ export function pruneClipboardHistoryOlderThan(retentionDays: number | null | un const removed = before - kept.length; if (removed > 0) { clipboardHistory = kept; - saveHistory(); + saveHistory({ flush: true }); console.log(`Pruned ${removed} clipboard item${removed === 1 ? '' : 's'} older than ${days} day${days === 1 ? '' : 's'}`); } return removed; } -export function clearClipboardHistory(): void { +export async function clearClipboardHistory(): Promise { // Delete all image files for (const item of clipboardHistory) { if (item.type === 'image' && fs.existsSync(item.content)) { @@ -946,11 +1039,12 @@ export function clearClipboardHistory(): void { } clipboardHistory = []; - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); console.log('Clipboard history cleared'); } -export function deleteClipboardItem(id: string): boolean { +export async function deleteClipboardItem(id: string): Promise { const index = clipboardHistory.findIndex((item) => item.id === id); if (index === -1) return false; @@ -964,7 +1058,8 @@ export function deleteClipboardItem(id: string): boolean { } clipboardHistory.splice(index, 1); - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); return true; } @@ -1132,7 +1227,7 @@ export function setClipboardMonitorEnabled(enabled: boolean): void { if (enabled && !pollInterval) { startClipboardMonitor(); } else if (!enabled && pollInterval) { - stopClipboardMonitor(); + void stopClipboardMonitor(); } } diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..b58fbf44 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; +import { createAerospaceWorkspaceMover } from './aerospace-workspace'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; import { @@ -100,6 +101,8 @@ import { togglePinClipboardItem, moveClipboardPinnedItem, pruneClipboardHistoryOlderThan, + flushClipboardHistoryWrites, + hasPendingClipboardHistoryWrites, } from './clipboard-manager'; import { initSnippetStore, @@ -193,6 +196,8 @@ import { exportNoteToFile, exportNotesToFile, importNotesFromFile, + flushNotesToDisk, + hasPendingNotesSave, } from './notes-store'; import { initCanvasStore, @@ -3198,48 +3203,12 @@ function setLauncherOverlayTopmost(enabled: boolean): void { * This is fire-and-forget — failures are silently ignored so we never * block or delay the launcher for users who don't use AeroSpace. */ -let aerospaceAvailable: boolean | null = null; // null = not yet checked +const aerospaceWorkspaceMover = createAerospaceWorkspaceMover({ + shouldRun: () => !!mainWindow && !mainWindow.isDestroyed(), +}); + function moveWindowToCurrentAerospaceWorkspace(): void { - if (aerospaceAvailable === false || process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return; - try { - const { execFileSync } = require('child_process'); - // Quick check: is AeroSpace running? list-workspaces --focused - // exits 0 only when the server is up. - const focusedWs = String( - execFileSync('aerospace', ['list-workspaces', '--focused'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!focusedWs) return; - aerospaceAvailable = true; - - // Find our window(s) by bundle-id - const windowsRaw = String( - execFileSync('aerospace', ['list-windows', '--all', '--app-bundle-id', 'com.supercmd.app', '--format', '%{window-id} %{workspace}'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!windowsRaw) return; - - for (const line of windowsRaw.split('\n')) { - const parts = line.trim().split(/\s+/); - if (parts.length < 2) continue; - const [windowId, currentWs] = parts; - if (currentWs === focusedWs) continue; // already on the right workspace - execFileSync('aerospace', ['move-node-to-workspace', focusedWs, '--window-id', windowId], { - timeout: 500, - stdio: 'ignore', - }); - } - } catch (err: any) { - // ENOENT = `aerospace` binary not found — will never appear, so skip future calls. - if (err?.code === 'ENOENT') { - aerospaceAvailable = false; - } - // Other errors (server not running, command failed) are transient — retry next time. - } + aerospaceWorkspaceMover.requestMove(); } function clearOAuthBlurHideSuppression(): void { @@ -13316,19 +13285,23 @@ async function restartAndInstallAppUpdate(): Promise { }); triggerTimer = setTimeout(() => { - try { - if (process.platform === 'darwin') { - try { - appUpdater.autoInstallOnAppQuit = true; - appUpdater.autoRunAppAfterInstall = true; - } catch {} - appUpdater.quitAndInstall(); - } else { - appUpdater.quitAndInstall(false, true); + void (async () => { + try { + await flushNotesToDisk(); + await flushClipboardHistoryWrites(); + if (process.platform === 'darwin') { + try { + appUpdater.autoInstallOnAppQuit = true; + appUpdater.autoRunAppAfterInstall = true; + } catch {} + appUpdater.quitAndInstall(); + } else { + appUpdater.quitAndInstall(false, true); + } + } catch (error: any) { + finish(false, error); } - } catch (error: any) { - finish(false, error); - } + })(); }, 40); timeoutTimer = setTimeout(() => { @@ -16450,12 +16423,12 @@ return appURL's |path|() as text`, return searchClipboardHistory(query); }); - ipcMain.handle('clipboard-clear-history', () => { - clearClipboardHistory(); + ipcMain.handle('clipboard-clear-history', async () => { + await clearClipboardHistory(); }); - ipcMain.handle('clipboard-delete-item', (_event: any, id: string) => { - return deleteClipboardItem(id); + ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) => { + return await deleteClipboardItem(id); }); ipcMain.handle('clipboard-copy-item', (_event: any, id: string) => { @@ -19343,8 +19316,45 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', () => { +let pendingAsyncStorageQuitFlushComplete = false; +let pendingAsyncStorageQuitFlushInProgress = false; + +app.on('before-quit', (event: any) => { prepareWindowsForAppQuit(); + + const updateRestartInProgress = Boolean(appUpdaterRestartPromise) || appUpdaterStatusSnapshot.state === 'restarting'; + // Update restarts flush async storage before quitAndInstall(); do not intercept + // the updater's own before-quit after that handoff has started. + const shouldBypassAsyncStorageQuitFlush = updateRestartInProgress; + const shouldFlushNotes = !shouldBypassAsyncStorageQuitFlush && hasPendingNotesSave(); + const shouldFlushClipboard = !shouldBypassAsyncStorageQuitFlush && hasPendingClipboardHistoryWrites(); + + if ( + pendingAsyncStorageQuitFlushComplete || + pendingAsyncStorageQuitFlushInProgress || + (!shouldFlushNotes && !shouldFlushClipboard) + ) { + return; + } + + pendingAsyncStorageQuitFlushInProgress = true; + event.preventDefault(); + Promise.all([ + shouldFlushNotes + ? flushNotesToDisk().catch((error) => { + console.error('[Notes] Failed to flush pending saves before quit:', error); + }) + : Promise.resolve(), + shouldFlushClipboard + ? flushClipboardHistoryWrites().catch((error) => { + console.error('Failed to flush clipboard history before quit:', error); + }) + : Promise.resolve(), + ]).finally(() => { + pendingAsyncStorageQuitFlushComplete = true; + pendingAsyncStorageQuitFlushInProgress = false; + app.quit(); + }); }); app.on('will-quit', () => { @@ -19370,7 +19380,7 @@ app.on('will-quit', () => { killParakeetServer(); killQwen3Server(); killAudioCapturer(); - stopClipboardMonitor(); + void stopClipboardMonitor(); stopSnippetExpander(); stopEmojiTriggerMonitor(); stopFileSearchIndexing(); diff --git a/src/main/notes-store.ts b/src/main/notes-store.ts index 39953508..1aeb5542 100644 --- a/src/main/notes-store.ts +++ b/src/main/notes-store.ts @@ -13,6 +13,7 @@ import { app, clipboard, dialog, BrowserWindow, SaveDialogOptions, OpenDialogOptions } from 'electron'; import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -46,6 +47,13 @@ export interface Note { // ─── Cache ────────────────────────────────────────────────────────── let notesCache: Note[] | null = null; +// Notes writes are intentionally debounced off the caller path; graceful quit +// and updater restart flows call flushNotesToDisk() to persist queued edits. +const NOTES_SAVE_DEBOUNCE_MS = 250; +let notesSaveTimer: ReturnType | null = null; +let notesSavePending = false; +let notesSaveDrainPromise: Promise | null = null; +let notesSaveTempCounter = 0; // ─── Paths ────────────────────────────────────────────────────────── @@ -88,15 +96,67 @@ function loadFromDisk(): Note[] { return []; } -function saveToDisk(): void { +async function writeNotesAtomically(data: string): Promise { + const filePath = getNotesFilePath(); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.${++notesSaveTempCounter}.tmp`; + try { - const filePath = getNotesFilePath(); - fs.writeFileSync(filePath, JSON.stringify(notesCache || [], null, 2), 'utf-8'); + await fsp.writeFile(tempPath, data, 'utf-8'); + await fsp.rename(tempPath, filePath); } catch (e) { - console.error('Failed to save notes to disk:', e); + fsp.unlink(tempPath).catch(() => {}); + throw e; + } +} + +function clearNotesSaveTimer(): void { + if (notesSaveTimer) { + clearTimeout(notesSaveTimer); + notesSaveTimer = null; } } +function drainNotesSaveQueue(): Promise { + if (notesSaveDrainPromise) return notesSaveDrainPromise; + + notesSaveDrainPromise = (async () => { + clearNotesSaveTimer(); + while (notesSavePending) { + notesSavePending = false; + const data = JSON.stringify(notesCache || [], null, 2); + try { + await writeNotesAtomically(data); + } catch (e) { + console.error('Failed to save notes to disk:', e); + } + clearNotesSaveTimer(); + } + })().finally(() => { + notesSaveDrainPromise = null; + }); + + return notesSaveDrainPromise; +} + +function saveToDisk(): void { + notesSavePending = true; + clearNotesSaveTimer(); + notesSaveTimer = setTimeout(() => { + notesSaveTimer = null; + void drainNotesSaveQueue(); + }, NOTES_SAVE_DEBOUNCE_MS); +} + +export function hasPendingNotesSave(): boolean { + return notesSavePending || notesSaveTimer !== null || notesSaveDrainPromise !== null; +} + +export async function flushNotesToDisk(): Promise { + clearNotesSaveTimer(); + if (!notesSavePending && !notesSaveDrainPromise) return; + await drainNotesSaveQueue(); +} + const VALID_THEMES: Set = new Set([ 'default', 'rose', 'orange', 'amber', 'emerald', 'cyan', 'blue', 'violet', 'fuchsia', 'slate', diff --git a/src/native/audio-capturer.swift b/src/native/audio-capturer.swift index 752b6e3c..72ec9efc 100644 --- a/src/native/audio-capturer.swift +++ b/src/native/audio-capturer.swift @@ -279,6 +279,16 @@ class AudioCapturer { // MARK: Buffer processing private func processBuffer(_ buffer: AVAudioPCMBuffer) { + let now = Date() + let shouldUpdateMeter = now.timeIntervalSince(lastMeterAt) >= meterInterval + + guard isRecording || shouldUpdateMeter else { return } + + if !isRecording { + updateMeter(fromInputBuffer: buffer, now: now) + return + } + guard let converted = convertBuffer(buffer), converted.frameLength > 0, let samples = converted.floatChannelData?[0] @@ -290,20 +300,8 @@ class AudioCapturer { ringBuffer.append(UnsafeBufferPointer(start: samples, count: sampleCount)) } - // Compute meter - let now = Date() - if now.timeIntervalSince(lastMeterAt) >= meterInterval { - var sumSquares: Float = 0 - var peak: Float = 0 - for i in 0.. 0 else { return } + + let channelStride = buffer.format.isInterleaved ? Int(max(1, buffer.format.channelCount)) : 1 + + switch buffer.format.commonFormat { + case .pcmFormatFloat32: + guard let samples = buffer.floatChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, now: now) + case .pcmFormatFloat64: + guard let rawSamples = buffer.audioBufferList.pointee.mBuffers.mData else { return } + updateMeter( + samples: rawSamples.assumingMemoryBound(to: Double.self), + sampleCount: sampleCount, + stride: channelStride, + now: now + ) + case .pcmFormatInt16: + guard let samples = buffer.int16ChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, divisor: Float(Int16.max), now: now) + case .pcmFormatInt32: + guard let samples = buffer.int32ChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, divisor: Float(Int32.max), now: now) + case .otherFormat: + return + @unknown default: + return + } + } + + private func updateMeter( + samples: UnsafePointer, + sampleCount: Int, + stride: Int, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0.., + sampleCount: Int, + stride: Int, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0..( + samples: UnsafePointer, + sampleCount: Int, + stride: Int, + divisor: Float, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0.. AVAudioPCMBuffer? { diff --git a/src/native/ax-caret-query.swift b/src/native/ax-caret-query.swift index 2bfdabbf..50fd5d9f 100644 --- a/src/native/ax-caret-query.swift +++ b/src/native/ax-caret-query.swift @@ -161,9 +161,12 @@ public enum AXCaretQuery { } // BFS up to a small depth to avoid walking huge trees. var queue: [(el: AXUIElement, depth: Int)] = [(root, 0)] + var queueIndex = 0 let maxDepth = 6 - while let (el, depth) = queue.first { - queue.removeFirst() + let maxElements = 240 + while queueIndex < queue.count && queueIndex < maxElements { + let (el, depth) = queue[queueIndex] + queueIndex += 1 if depth > 0 { let role = copyString(el, kAXRoleAttribute as CFString) ?? "" if TEXT_LEAF_ROLES.contains(role) && hasSelectedTextRange(el) { @@ -183,13 +186,16 @@ public enum AXCaretQuery { var focusedRaw: AnyObject? if AXUIElementCopyAttributeValue(el, kAXFocusedUIElementAttribute as CFString, &focusedRaw) == .success, let next = focusedRaw, CFGetTypeID(next) == AXUIElementGetTypeID() { - queue.append((next as! AXUIElement, depth + 1)) + if queue.count < maxElements { + queue.append((next as! AXUIElement, depth + 1)) + } continue } var childrenRaw: AnyObject? if AXUIElementCopyAttributeValue(el, kAXChildrenAttribute as CFString, &childrenRaw) == .success, let arr = childrenRaw as? [AXUIElement] { for child in arr { + if queue.count >= maxElements { break } queue.append((child, depth + 1)) } } diff --git a/src/native/get-selected-text.swift b/src/native/get-selected-text.swift index 7e4f4112..3b86892b 100644 --- a/src/native/get-selected-text.swift +++ b/src/native/get-selected-text.swift @@ -163,12 +163,14 @@ private func enqueueChildren(of element: AXUIElement, depth: Int, into queue: in private func findSelectedText(from roots: [AXUIElement]) -> String? { var queue = roots.map { ($0, 0) } + var queueIndex = 0 var inspected = 0 let maxDepth = 8 let maxElements = 240 - while let (element, depth) = queue.first { - queue.removeFirst() + while queueIndex < queue.count { + let (element, depth) = queue[queueIndex] + queueIndex += 1 inspected += 1 if inspected > maxElements { break } diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..118ce3dd 100644 --- a/src/renderer/src/CameraExtension.tsx +++ b/src/renderer/src/CameraExtension.tsx @@ -1,6 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, Camera, Image, RefreshCw, RotateCcw, Settings, Video, X } from 'lucide-react'; import ExtensionActionFooter from './components/ExtensionActionFooter'; +import { + type CapturePreviewUrlManager, + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} from './camera-capture'; interface CameraExtensionProps { onClose: () => void; @@ -78,7 +84,7 @@ const CameraExtension: React.FC = ({ onClose }) => { const [stream, setStream] = useState(null); const [showActions, setShowActions] = useState(false); const [selectedActionIndex, setSelectedActionIndex] = useState(0); - const [capturePreviewDataUrl, setCapturePreviewDataUrl] = useState(null); + const [capturePreviewUrl, setCapturePreviewUrl] = useState(null); const [capturePreviewVisible, setCapturePreviewVisible] = useState(false); const [captureStatus, setCaptureStatus] = useState(null); const [flashVisible, setFlashVisible] = useState(false); @@ -95,6 +101,11 @@ const CameraExtension: React.FC = ({ onClose }) => { const startRequestIdRef = useRef(0); const unmountedRef = useRef(false); const streamRef = useRef(null); + const capturePreviewUrlManagerRef = useRef(null); + + if (capturePreviewUrlManagerRef.current === null) { + capturePreviewUrlManagerRef.current = createCapturePreviewUrlManager(); + } const clearTransientUi = useCallback(() => { if (captureNoticeTimerRef.current != null) { @@ -115,6 +126,45 @@ const CameraExtension: React.FC = ({ onClose }) => { } }, []); + const clearCapturePreview = useCallback(() => { + capturePreviewUrlManagerRef.current?.clear(); + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + }, []); + + const showCapturePreview = useCallback( + (blob: Blob) => { + const previewUrlManager = capturePreviewUrlManagerRef.current; + if (!previewUrlManager) return; + + if (capturePreviewFadeTimerRef.current != null) { + window.clearTimeout(capturePreviewFadeTimerRef.current); + } + if (capturePreviewClearTimerRef.current != null) { + window.clearTimeout(capturePreviewClearTimerRef.current); + } + + const preview = createCapturePreview(blob, previewUrlManager); + if (!preview.url) { + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + return; + } + + setCapturePreviewUrl(preview.url); + setCapturePreviewVisible(preview.visible); + capturePreviewFadeTimerRef.current = window.setTimeout(() => { + setCapturePreviewVisible(false); + capturePreviewFadeTimerRef.current = null; + }, 5000); + capturePreviewClearTimerRef.current = window.setTimeout(() => { + clearCapturePreview(); + capturePreviewClearTimerRef.current = null; + }, 5300); + }, + [clearCapturePreview] + ); + const refocusCameraRoot = useCallback(() => { window.requestAnimationFrame(() => { rootRef.current?.focus(); @@ -284,22 +334,6 @@ const CameraExtension: React.FC = ({ onClose }) => { context.drawImage(video, 0, 0, width, height); } - setCapturePreviewDataUrl(canvas.toDataURL('image/png')); - setCapturePreviewVisible(true); - if (capturePreviewFadeTimerRef.current != null) { - window.clearTimeout(capturePreviewFadeTimerRef.current); - } - if (capturePreviewClearTimerRef.current != null) { - window.clearTimeout(capturePreviewClearTimerRef.current); - } - capturePreviewFadeTimerRef.current = window.setTimeout(() => { - setCapturePreviewVisible(false); - capturePreviewFadeTimerRef.current = null; - }, 5000); - capturePreviewClearTimerRef.current = window.setTimeout(() => { - setCapturePreviewDataUrl(null); - setCapturePreviewClearTimerRef.current = null; - }, 5300); setFlashVisible(true); if (flashTimerRef.current != null) { window.clearTimeout(flashTimerRef.current); @@ -316,9 +350,10 @@ const CameraExtension: React.FC = ({ onClose }) => { const timestamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}_${two(now.getHours())}-${two(now.getMinutes())}-${two(now.getSeconds())}`; const savePath = `${saveDir}/supercmd-capture-${timestamp}.png`; - let captureBlob: Blob | null = await new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), 'image/png'); - }); + const captureBlob = await encodeCanvasAsPngBlob(canvas); + if (captureBlob && !unmountedRef.current) { + showCapturePreview(captureBlob); + } let savedToDisk = false; if (captureBlob) { @@ -349,7 +384,7 @@ const CameraExtension: React.FC = ({ onClose }) => { showCaptureStatus({ kind: 'neutral', text: 'Failed to copy picture to clipboard.' }, 3000); } refocusCameraRoot(); - }, [isHorizontallyFlipped, refocusCameraRoot, showCaptureStatus, stream]); + }, [isHorizontallyFlipped, refocusCameraRoot, showCapturePreview, showCaptureStatus, stream]); const openSystemCameraSettings = useCallback(async () => { try { @@ -363,6 +398,7 @@ const CameraExtension: React.FC = ({ onClose }) => { unmountedRef.current = true; startRequestIdRef.current += 1; clearTransientUi(); + capturePreviewUrlManagerRef.current?.dispose(); const currentStream = streamRef.current; streamRef.current = null; stopMediaStream(currentStream); @@ -586,14 +622,14 @@ const CameraExtension: React.FC = ({ onClose }) => { {selectedCameraLabel} - {capturePreviewDataUrl ? ( + {capturePreviewUrl ? (
Latest capture string; + revokeObjectURL: (objectUrl: string) => void; +} + +export interface CapturePreviewUrlManager { + getCurrentUrl: () => string | null; + show: (blob: Blob) => string | null; + clear: () => void; + dispose: () => void; +} + +function getCapturePreviewUrlApi(): CapturePreviewUrlApi | null { + if ( + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return null; + } + return URL; +} + +export function createCapturePreviewUrlManager( + urlApi: CapturePreviewUrlApi | null = getCapturePreviewUrlApi() +): CapturePreviewUrlManager { + let currentUrl: string | null = null; + + const clear = () => { + const urlToRevoke = currentUrl; + currentUrl = null; + if (!urlToRevoke || !urlApi) return; + try { + urlApi.revokeObjectURL(urlToRevoke); + } catch {} + }; + + return { + getCurrentUrl: () => currentUrl, + show: (blob: Blob) => { + let nextUrl: string | null = null; + if (urlApi) { + try { + nextUrl = urlApi.createObjectURL(blob); + } catch { + nextUrl = null; + } + } + clear(); + currentUrl = nextUrl; + return currentUrl; + }, + clear, + dispose: clear, + }; +} + +export function createCapturePreview( + blob: Blob, + previewUrlManager: CapturePreviewUrlManager +): { url: string | null; visible: boolean } { + const url = previewUrlManager.show(blob); + return { + url, + visible: Boolean(url), + }; +} + +export function encodeCanvasAsPngBlob(canvas: Pick): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png'); + }); +} diff --git a/src/renderer/src/hooks/useSpeakManager.ts b/src/renderer/src/hooks/useSpeakManager.ts index 1e3f8cb3..3dfb070a 100644 --- a/src/renderer/src/hooks/useSpeakManager.ts +++ b/src/renderer/src/hooks/useSpeakManager.ts @@ -10,8 +10,8 @@ * - handleSpeakVoiceChange / handleSpeakRateChange: persist user selections to settings * - Opens a detached portal window for the speak overlay via useDetachedPortalWindow * - * Polls speak status from the main process while the overlay is visible, and syncs - * the configured voice from settings each time the overlay opens. + * Listens for speak status updates from the main process, and syncs + * the configured voice from initial settings plus live settings updates. */ import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; @@ -23,9 +23,19 @@ import { getCachedElevenLabsVoices, setCachedElevenLabsVoices, } from '../utils/voice-cache'; +import { + applySpeakSettings, + buildElevenLabsSpeakModel, + DEFAULT_EDGE_TTS_VOICE, + DEFAULT_ELEVENLABS_VOICE_ID, + DEFAULT_TTS_MODEL, + parseElevenLabsSpeakModel, + type SpeakOptions, + type SpeakSettingsSnapshot, +} from '../utils/speak-settings-sync'; const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ - { id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel' }, + { id: DEFAULT_ELEVENLABS_VOICE_ID, label: 'Rachel' }, { id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi' }, { id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella' }, { id: 'ErXwobaYiN019PkySvjV', label: 'Antoni' }, @@ -36,23 +46,6 @@ const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ { id: 'yoZ06aMxZJJ28mfd3POQ', label: 'Sam' }, ]; -const DEFAULT_ELEVENLABS_VOICE_ID = ELEVENLABS_VOICES[0].id; - -function parseElevenLabsSpeakModel(raw: string): { model: string; voiceId: string } { - const value = String(raw || '').trim(); - const explicitVoice = /@([A-Za-z0-9]{8,})$/.exec(value)?.[1]; - const modelOnly = explicitVoice ? value.replace(/@[A-Za-z0-9]{8,}$/, '') : value; - const model = modelOnly.startsWith('elevenlabs-') ? modelOnly : 'elevenlabs-multilingual-v2'; - const voiceId = explicitVoice || DEFAULT_ELEVENLABS_VOICE_ID; - return { model, voiceId }; -} - -function buildElevenLabsSpeakModel(model: string, voiceId: string): string { - const normalizedModel = String(model || '').trim() || 'elevenlabs-multilingual-v2'; - const normalizedVoice = String(voiceId || '').trim() || DEFAULT_ELEVENLABS_VOICE_ID; - return `${normalizedModel}@${normalizedVoice}`; -} - // ─── Types ─────────────────────────────────────────────────────────── export interface SpeakStatus { @@ -71,7 +64,7 @@ export interface UseSpeakManagerOptions { export interface UseSpeakManagerReturn { speakStatus: SpeakStatus; - speakOptions: { voice: string; rate: string }; + speakOptions: SpeakOptions; edgeTtsVoices: EdgeTtsVoice[]; configuredEdgeTtsVoice: string; configuredTtsModel: string; @@ -98,18 +91,24 @@ export function useSpeakManager({ index: 0, total: 0, }); - const [speakOptions, setSpeakOptions] = useState<{ voice: string; rate: string }>({ - voice: 'en-US-EricNeural', + const [speakOptions, setSpeakOptions] = useState({ + voice: DEFAULT_EDGE_TTS_VOICE, rate: '+0%', }); const [edgeTtsVoices, setEdgeTtsVoices] = useState([]); const [elevenLabsVoices, setElevenLabsVoices] = useState([]); - const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState('en-US-EricNeural'); - const [configuredTtsModel, setConfiguredTtsModel] = useState('edge-tts'); + const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState(DEFAULT_EDGE_TTS_VOICE); + const [configuredTtsModel, setConfiguredTtsModel] = useState(DEFAULT_TTS_MODEL); + const speakOptionsVoiceRef = useRef(speakOptions.voice); const speakSessionShownRef = useRef(false); const pauseToggleInFlightRef = useRef(false); + const applySpeakOptions = useCallback((next: SpeakOptions) => { + speakOptionsVoiceRef.current = next.voice; + setSpeakOptions(next); + }, []); + // ── Portal ───────────────────────────────────────────────────────── const speakPortalTarget = useDetachedPortalWindow(showSpeak, { @@ -135,7 +134,7 @@ export function useSpeakManager({ useEffect(() => { let disposed = false; window.electron.speakGetOptions().then((options) => { - if (!disposed && options) setSpeakOptions(options); + if (!disposed && options) applySpeakOptions(options); }).catch(() => {}); window.electron.speakGetStatus().then((status) => { if (!disposed && status) setSpeakStatus(status); @@ -147,7 +146,7 @@ export function useSpeakManager({ disposed = true; disposeSpeak(); }; - }, []); + }, [applySpeakOptions]); // Edge TTS voice list fetch useEffect(() => { @@ -205,49 +204,30 @@ export function useSpeakManager({ }; }, [configuredTtsModel]); - // Sync configured voice from settings whenever they change + // Sync configured voice from initial settings and live settings updates useEffect(() => { let disposed = false; - const syncFromSettings = async () => { - try { - const settings = await window.electron.getSettings(); - if (disposed) return; - - const ttsModel = String(settings.ai?.textToSpeechModel || 'edge-tts'); - const edgeVoice = String(settings.ai?.edgeTtsVoice || 'en-US-EricNeural'); - - setConfiguredTtsModel(ttsModel); - setConfiguredEdgeTtsVoice(edgeVoice); - - // Also sync speakOptions to match settings - const usingElevenLabs = ttsModel.startsWith('elevenlabs-'); - const targetVoice = usingElevenLabs - ? parseElevenLabsSpeakModel(ttsModel).voiceId - : edgeVoice; - - if (targetVoice && targetVoice !== speakOptions.voice) { - const next = await window.electron.speakUpdateOptions({ - voice: targetVoice, - restartCurrent: false, - }); - setSpeakOptions(next); - } - } catch { - // Ignore errors - } + const syncFromSettings = (settings: SpeakSettingsSnapshot | null | undefined) => { + void applySpeakSettings(settings, { + getCurrentVoice: () => speakOptionsVoiceRef.current, + setConfiguredTtsModel, + setConfiguredEdgeTtsVoice, + updateSpeakOptions: (patch) => window.electron.speakUpdateOptions(patch), + setSpeakOptions: applySpeakOptions, + isDisposed: () => disposed, + }).catch(() => {}); }; - - // Initial sync - syncFromSettings(); - - // Poll for settings changes every 2 seconds while widget is relevant - const interval = setInterval(syncFromSettings, 2000); - + + window.electron.getSettings().then(syncFromSettings).catch(() => {}); + const cleanupSettings = window.electron.onSettingsUpdated?.((settings) => { + syncFromSettings(settings); + }); + return () => { disposed = true; - clearInterval(interval); + cleanupSettings?.(); }; - }, []); + }, [applySpeakOptions]); // Auto-sync configured voice when speak view opens useEffect(() => { @@ -266,9 +246,9 @@ export function useSpeakManager({ voice: targetVoice, restartCurrent: true, }).then((next) => { - setSpeakOptions(next); + applySpeakOptions(next); }).catch(() => {}); - }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice]); + }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice, applySpeakOptions]); // ── Memos ────────────────────────────────────────────────────────── @@ -317,7 +297,7 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} return; } @@ -333,17 +313,17 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} - }, [configuredTtsModel]); + }, [configuredTtsModel, applySpeakOptions]); const handleSpeakRateChange = useCallback(async (rate: string) => { const next = await window.electron.speakUpdateOptions({ rate, restartCurrent: true, }); - setSpeakOptions(next); - }, []); + applySpeakOptions(next); + }, [applySpeakOptions]); const handleSpeakTogglePause = useCallback(async () => { if (pauseToggleInFlightRef.current) return; diff --git a/src/renderer/src/utils/speak-settings-sync.ts b/src/renderer/src/utils/speak-settings-sync.ts new file mode 100644 index 00000000..4d742872 --- /dev/null +++ b/src/renderer/src/utils/speak-settings-sync.ts @@ -0,0 +1,77 @@ +export const DEFAULT_TTS_MODEL = 'edge-tts'; +export const DEFAULT_EDGE_TTS_VOICE = 'en-US-EricNeural'; +export const DEFAULT_ELEVENLABS_VOICE_ID = '21m00Tcm4TlvDq8ikWAM'; + +export interface SpeakOptions { + voice: string; + rate: string; +} + +export interface SpeakSettingsSnapshot { + ai?: { + textToSpeechModel?: string | null; + edgeTtsVoice?: string | null; + } | null; +} + +export interface ResolvedSpeakSettings { + ttsModel: string; + edgeVoice: string; + targetVoice: string; +} + +export function parseElevenLabsSpeakModel(raw: string): { model: string; voiceId: string } { + const value = String(raw || '').trim(); + const explicitVoice = /@([A-Za-z0-9]{8,})$/.exec(value)?.[1]; + const modelOnly = explicitVoice ? value.replace(/@[A-Za-z0-9]{8,}$/, '') : value; + const model = modelOnly.startsWith('elevenlabs-') ? modelOnly : 'elevenlabs-multilingual-v2'; + const voiceId = explicitVoice || DEFAULT_ELEVENLABS_VOICE_ID; + return { model, voiceId }; +} + +export function buildElevenLabsSpeakModel(model: string, voiceId: string): string { + const normalizedModel = String(model || '').trim() || 'elevenlabs-multilingual-v2'; + const normalizedVoice = String(voiceId || '').trim() || DEFAULT_ELEVENLABS_VOICE_ID; + return `${normalizedModel}@${normalizedVoice}`; +} + +export function resolveSpeakSettings(settings: SpeakSettingsSnapshot | null | undefined): ResolvedSpeakSettings { + const ttsModel = String(settings?.ai?.textToSpeechModel || DEFAULT_TTS_MODEL); + const edgeVoice = String(settings?.ai?.edgeTtsVoice || DEFAULT_EDGE_TTS_VOICE); + const targetVoice = ttsModel.startsWith('elevenlabs-') + ? parseElevenLabsSpeakModel(ttsModel).voiceId + : edgeVoice; + + return { ttsModel, edgeVoice, targetVoice }; +} + +export async function applySpeakSettings( + settings: SpeakSettingsSnapshot | null | undefined, + options: { + getCurrentVoice: () => string; + setConfiguredTtsModel: (value: string) => void; + setConfiguredEdgeTtsVoice: (value: string) => void; + updateSpeakOptions: (patch: { voice: string; restartCurrent: false }) => Promise; + setSpeakOptions: (next: SpeakOptions) => void; + isDisposed?: () => boolean; + } +): Promise { + const resolved = resolveSpeakSettings(settings); + if (options.isDisposed?.()) return resolved; + + options.setConfiguredTtsModel(resolved.ttsModel); + options.setConfiguredEdgeTtsVoice(resolved.edgeVoice); + + if (!resolved.targetVoice || resolved.targetVoice === options.getCurrentVoice()) { + return resolved; + } + + const next = await options.updateSpeakOptions({ + voice: resolved.targetVoice, + restartCurrent: false, + }); + if (!next || options.isDisposed?.()) return resolved; + + options.setSpeakOptions(next); + return resolved; +}