diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..36d4a79c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,9 @@ on: jobs: claude-review: + # Fork PRs do not receive the OIDC token/secrets required by + # anthropics/claude-code-action on pull_request workflows. + 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 +44,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/project-checks.yml b/.github/workflows/project-checks.yml new file mode 100644 index 00000000..959936a5 --- /dev/null +++ b/.github/workflows/project-checks.yml @@ -0,0 +1,61 @@ +name: Project Checks + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: project-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-i18n-build: + name: Tests, i18n, and build + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: true + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + SUPERCMD_SKIP_ELECTRON_TESTS: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies without native postinstall scripts + run: npm ci --ignore-scripts + + - name: Check i18n + run: npm run check:i18n + + - name: Build main process + run: npm run build:main + + - name: Typecheck renderer + run: npm run typecheck:renderer + + - name: Run unit tests + run: npm run test:ci + + - name: Run perf regression budgets + run: npm run test:perf:ci + + - name: Build renderer + run: npm run build:renderer diff --git a/.gitignore b/.gitignore index 8732339c..b72a369e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +pnpm-lock.yaml dist/ out/ .DS_Store @@ -11,4 +12,4 @@ extensions/* **/.build/* .gstack/ -**/AeroSpace-main/** \ No newline at end of file +**/AeroSpace-main/** diff --git a/package-lock.json b/package-lock.json index cbe18d8a..6d7d0d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -14,7 +14,6 @@ "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -39,6 +38,9 @@ "typescript": "^5.3.3", "vite": "^5.0.11", "wait-on": "^7.2.0" + }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" } }, "node_modules/@alloc/quick-lru": { @@ -3420,6 +3422,7 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", + "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -4631,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/electron-liquid-glass/-/electron-liquid-glass-1.1.1.tgz", "integrity": "sha512-AfPxcu6RJYxlsW2sjgjDEON0rVOjhh5QYM6ur5hsfILQmfY5ewHCWJZJZoDsQxhjeW1DAhbRbvB79IvgW4ansA==", "license": "MIT", + "optional": true, "os": [ "darwin" ], @@ -4650,6 +4654,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", "license": "MIT", + "optional": true, "engines": { "node": "^18 || ^20 || >= 21" } @@ -5144,7 +5149,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/filelist": { "version": "1.0.4", diff --git a/package.json b/package.json index f262a8c1..6204d266 100644 --- a/package.json +++ b/package.json @@ -6,19 +6,31 @@ "homepage": "https://supercmd.sh", "main": "dist/main/main.js", "repository": "https://github.com/SuperCmdLabs/SuperCmd", + "packageManager": "npm@11.12.1", "scripts": { "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 SUPERCMD_OPEN_DEVTOOLS_ON_STARTUP=1 electron .", "build": "npm run build:main && npm run build:renderer && npm run build:native", + "prebuild:main": "node scripts/check-package-manager-parity.mjs", "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", + "pretypecheck:renderer": "node scripts/check-package-manager-parity.mjs", + "typecheck:renderer": "tsc -p tsconfig.renderer.json --noEmit --pretty false", + "prebuild:renderer": "node scripts/check-package-manager-parity.mjs", "build:renderer": "vite build", + "measure:launcher-command-list": "node scripts/measure-launcher-command-list-render.mjs", + "precheck:i18n": "node scripts/check-package-manager-parity.mjs", "check:i18n": "node scripts/check-i18n.mjs", - "test": "node --test 'scripts/test-*.mjs'", + "check:package-manager": "node scripts/check-package-manager-parity.mjs", + "perf:file-search": "node scripts/file-search-perf-harness.mjs", + "test:ci": "node scripts/run-node-tests.mjs --exclude-perf", + "test:perf:ci": "cross-env SUPERCMD_PERF_CI=1 SUPERCMD_PERF_REPORT=1 node --test scripts/test-browser-search-performance.mjs scripts/test-file-search-perf-harness.mjs && cross-env SUPERCMD_ROOT_SEARCH_PERF_JSON=1 SUPERCMD_ROOT_SEARCH_PERF_COMMANDS=5000 SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS=3 SUPERCMD_ROOT_SEARCH_PERF_WARMUPS=1 SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS=900 SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS=350 SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS=500 SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN=1.05 node scripts/test-root-search-perf.mjs", + "pretest": "node scripts/check-package-manager-parity.mjs", + "test": "node scripts/run-node-tests.mjs --exclude-perf", "build:native": "node scripts/build-native.mjs", "ensure:cross-arch-esbuild": "node scripts/ensure-cross-arch-esbuild.mjs", - "postinstall": "node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", + "postinstall": "node scripts/ensure-macos-runtime-deps.mjs && node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", "start": "electron .", "package": "cross-env NODE_ENV=production npm run build && electron-builder", "package:unsigned": "cross-env NODE_ENV=production CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && electron-builder --mac dmg -c.mac.identity=null -c.mac.notarize=false" @@ -42,12 +54,9 @@ }, "dependencies": { "@aptabase/electron": "^0.3.1", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -57,6 +66,9 @@ "react-dom": "^18.2.0", "transliteration": "^2.6.1" }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" + }, "build": { "appId": "com.supercmd.app", "productName": "SuperCmd", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..99aab3a6 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + electron: true + esbuild: true + extract-file-icon: true + node-window-manager: true 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.. 0) { + fs.mkdirSync(iconsDir, { recursive: true }); + } + + for (let i = 0; i < fileCount; i += 1) { + const scriptPath = path.join(scriptsDir, `large-command-${String(i + 1).padStart(3, '0')}.sh`); + let icon = '⚡'; + if (iconBytesPerFile > 0) { + icon = `.icons/icon-${String(i + 1).padStart(3, '0')}.png`; + fs.writeFileSync(path.join(scriptsDir, icon), Buffer.alloc(iconBytesPerFile, i % 251)); + } + fs.writeFileSync(scriptPath, makeLargeScript(`Large Command ${i + 1}`, bodyBytesPerFile, icon), { + mode: 0o755, + }); + } + + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + const { module: runner, metrics, resetMetrics } = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs: true, + }); + + resetMetrics(); + const discoveryStart = performance.now(); + const commands = runner.discoverScriptCommands(); + const discoveryMs = performance.now() - discoveryStart; + const discoveryMetrics = snapshot(metrics); + + if (commands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands, discovered ${commands.length}`); + } + + resetMetrics(); + const executeStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const executeMs = performance.now() - executeStart; + const executeMetrics = snapshot(metrics); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedDiscoveryStart = performance.now(); + const rediscoveredCommands = runner.discoverScriptCommands(); + const invalidatedDiscoveryMs = performance.now() - invalidatedDiscoveryStart; + const invalidatedDiscoveryMetrics = snapshot(metrics); + + if (rediscoveredCommands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands after invalidation, discovered ${rediscoveredCommands.length}`); + } + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedExecuteStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const invalidatedExecuteMs = performance.now() - invalidatedExecuteStart; + const invalidatedExecuteMetrics = snapshot(metrics); + + console.log('Script command discovery benchmark'); + console.log(`files: ${fileCount}`); + console.log(`body bytes per file: ${formatBytes(bodyBytesPerFile)}`); + console.log(`icon bytes per file: ${iconBytesPerFile > 0 ? formatBytes(iconBytesPerFile) : 'emoji'}`); + printMetric('discovery', discoveryMs, discoveryMetrics); + printMetric('cached execution shebang lookup', executeMs, executeMetrics); + printMetric('invalidated discovery', invalidatedDiscoveryMs, invalidatedDiscoveryMetrics); + printMetric('invalidated execution lookup', invalidatedExecuteMs, invalidatedExecuteMetrics); +} finally { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/benchmark-file-search-delete-batch.mjs b/scripts/benchmark-file-search-delete-batch.mjs new file mode 100644 index 00000000..946f8bec --- /dev/null +++ b/scripts/benchmark-file-search-delete-batch.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + indexEntry, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, +}; +`; + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +function addEntry(api, snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function buildDirectoryScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench'; + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'deleted-root'); + const survivorRoot = path.join(homeDir, 'survivors'); + const deletePaths = [deletedRoot]; + + addEntry(api, snapshot, deletedRoot, true); + for (let group = 0; group < 40; group += 1) { + const groupDir = path.join(deletedRoot, `group-${group}`); + addEntry(api, snapshot, groupDir, true); + deletePaths.push(groupDir); + for (let item = 0; item < 100; item += 1) { + const nestedDir = path.join(groupDir, `nested-${item}`); + addEntry(api, snapshot, nestedDir, true); + addEntry(api, snapshot, path.join(nestedDir, `deleted-${group}-${item}.txt`)); + deletePaths.push(nestedDir); + } + } + + addEntry(api, snapshot, survivorRoot, true); + for (let dir = 0; dir < 120; dir += 1) { + const dirPath = path.join(survivorRoot, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + addEntry(api, snapshot, path.join(dirPath, `survivor-${dir}-${file}.txt`)); + } + } + + return { + deletePaths, + deletedRoot, + expectedDeletedCount: 1 + (40 * 100) + 40 + (40 * 100), + name: 'directory-tree-batch', + snapshot, + survivorPath: path.join(survivorRoot, 'dir-119', 'survivor-119-499.txt'), + }; +} + +function buildDirectFileScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench-direct'; + const snapshot = api.makeSnapshot(); + const rootDir = path.join(homeDir, 'projects'); + const deletePaths = []; + + addEntry(api, snapshot, rootDir, true); + for (let dir = 0; dir < 160; dir += 1) { + const dirPath = path.join(rootDir, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + const filePath = path.join(dirPath, `file-${dir}-${file}.txt`); + addEntry(api, snapshot, filePath); + if (deletePaths.length < 500 && file % 8 === 0) { + deletePaths.push(filePath); + } + } + } + + return { + deletePaths, + expectedDeletedCount: deletePaths.length, + name: 'direct-file-batch', + snapshot, + survivorPath: path.join(rootDir, 'dir-159', 'file-159-499.txt'), + }; +} + +function measureScenario(api, scenario) { + const started = performance.now(); + api.tombstoneDeletedPaths(scenario.snapshot, scenario.deletePaths); + const elapsedMs = performance.now() - started; + + let deletedCount = 0; + for (const entry of scenario.snapshot.entries) { + if (entry.deleted) deletedCount += 1; + } + + const survivorId = scenario.snapshot.pathToEntryId.get(scenario.survivorPath); + assert.equal(deletedCount, scenario.expectedDeletedCount); + assert.notEqual(survivorId, undefined); + assert.equal(scenario.snapshot.entries[survivorId].deleted, undefined); + + return { + name: scenario.name, + entries: scenario.snapshot.entries.length, + deletePaths: scenario.deletePaths.length, + deletedCount, + elapsedMs: Number(elapsedMs.toFixed(3)), + }; +} + +const api = loadFileSearchIndexInternals(); +const scenarios = [ + measureScenario(api, buildDirectFileScenario(api)), + measureScenario(api, buildDirectoryScenario(api)), +]; + +console.log(JSON.stringify({ + benchmark: 'file-search-delete-batch', + scenarios, +}, null, 2)); diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 35f3172f..5cfa7edb 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -1,19 +1,78 @@ #!/usr/bin/env node import { execSync } from 'child_process'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); mkdirSync('dist/native', { recursive: true }); const electronVersion = require('../node_modules/electron/package.json').version; const arch = process.arch; +const stampVersion = 1; function run(cmd) { execSync(cmd, { stdio: 'inherit' }); } +function readStamp(stampPath) { + try { + return JSON.parse(readFileSync(stampPath, 'utf8')); + } catch { + return null; + } +} + +function inputState(filePath) { + if (!existsSync(filePath)) return null; + const stats = statSync(filePath); + return { + path: filePath, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; +} + +function isUpToDate(out, stampPath, stamp) { + if (!existsSync(out)) return false; + const currentInputs = stamp.inputs.map(inputState); + if (currentInputs.some((input) => input === null)) return false; + + const previous = readStamp(stampPath); + if (JSON.stringify(previous) !== JSON.stringify({ ...stamp, inputState: currentInputs })) { + return false; + } + + const outputMtime = statSync(out).mtimeMs; + return currentInputs.every((input) => input.mtimeMs <= outputMtime); +} + +function writeStamp(stampPath, stamp) { + const currentInputs = stamp.inputs.map(inputState); + writeFileSync(stampPath, JSON.stringify({ ...stamp, inputState: currentInputs }, null, 2)); +} + +function buildIfStale({ label, out, inputs, command }) { + const stampPath = `${out}.stamp.json`; + const stamp = { + version: stampVersion, + label, + command, + inputs, + }; + + if (isUpToDate(out, stampPath, stamp)) { + console.log(`[native] ${path.basename(out)} is up to date, skipping rebuild`); + return; + } + + run(command); + writeStamp(stampPath, stamp); +} + const swift = [ ['dist/native/get-selected-text', 'src/native/get-selected-text.swift', '-framework Foundation -framework ApplicationServices -framework AppKit'], @@ -28,7 +87,7 @@ const swift = [ ['dist/native/menu-item-search', 'src/native/menu-item-search.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/emoji-trigger-monitor', - 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift', + 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift src/native/emoji-caret-session-cache.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/hotkey-hold-monitor', 'src/native/hotkey-hold-monitor.swift', '-framework CoreGraphics -framework AppKit -framework Carbon'], @@ -49,17 +108,34 @@ const swift = [ ]; for (const [out, src, frameworks] of swift) { - run(`swiftc -O -o ${out} ${src} ${frameworks}`); + const inputs = [...src.split(' '), __filename]; + buildIfStale({ + label: path.basename(out), + out, + inputs, + command: `swiftc -O -o ${out} ${src} ${frameworks}`, + }); } // Build native Node addon (native_helpers.node) -run( - `cd src/native/native-helpers-addon && ` + - `HOME=~/.electron-gyp npx node-gyp rebuild ` + - `--target=${electronVersion} --arch=${arch} ` + - `--dist-url=https://electronjs.org/headers && ` + - `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node` -); +buildIfStale({ + label: 'native_helpers.node', + out: 'dist/native/native_helpers.node', + inputs: [ + 'src/native/native-helpers-addon/binding.gyp', + 'src/native/native-helpers-addon/native_helpers.mm', + 'package.json', + 'package-lock.json', + 'node_modules/electron/package.json', + __filename, + ], + command: + `cd src/native/native-helpers-addon && ` + + `HOME=~/.electron-gyp npx node-gyp rebuild ` + + `--target=${electronVersion} --arch=${arch} ` + + `--dist-url=https://electronjs.org/headers && ` + + `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node`, +}); run('node scripts/build-whispercpp.mjs'); run('node scripts/build-parakeet.mjs'); diff --git a/scripts/build-parakeet.mjs b/scripts/build-parakeet.mjs index 71fd8ff3..d837b2af 100644 --- a/scripts/build-parakeet.mjs +++ b/scripts/build-parakeet.mjs @@ -14,8 +14,10 @@ const distNativeDir = path.join(repoRoot, 'dist', 'native'); const binaryDest = path.join(distNativeDir, 'parakeet-transcriber'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-soulver-calculator.mjs b/scripts/build-soulver-calculator.mjs index e02d8a4d..75f7e75f 100644 --- a/scripts/build-soulver-calculator.mjs +++ b/scripts/build-soulver-calculator.mjs @@ -15,8 +15,10 @@ const binaryDest = path.join(outDir, 'soulver-calculator'); const frameworkDest = path.join(outDir, 'SoulverCore.framework'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-whispercpp.mjs b/scripts/build-whispercpp.mjs index cad84f2c..81a40e61 100644 --- a/scripts/build-whispercpp.mjs +++ b/scripts/build-whispercpp.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; import { createHash } from 'crypto'; import path from 'path'; import { tmpdir } from 'os'; @@ -23,6 +23,18 @@ const runtimeDir = path.join(distNativeDir, 'whisper-runtime'); const frameworkDir = path.join(runtimeDir, 'whisper.framework'); const transcriberSource = path.join(repoRoot, 'src', 'native', 'whisper-transcriber.swift'); const transcriberBinary = path.join(distNativeDir, 'whisper-transcriber'); +const transcriberStamp = path.join(distNativeDir, 'whisper-transcriber.stamp.json'); +const stampVersion = 1; +const transcriberBuildArgs = [ + '-O', + '-module-cache-path', '/supercmd-swift-module-cache', + '-F', runtimeDir, + '-framework', 'whisper', + '-Xlinker', '-rpath', + '-Xlinker', '@executable_path/whisper-runtime', + '-o', transcriberBinary, + transcriberSource, +]; function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -85,17 +97,66 @@ function buildTranscriber() { console.log('[whisper.cpp] Building whisper-transcriber'); run('swiftc', [ - '-O', - '-module-cache-path', moduleCacheDir, - '-F', runtimeDir, - '-framework', 'whisper', - '-Xlinker', '-rpath', - '-Xlinker', '@executable_path/whisper-runtime', - '-o', transcriberBinary, - transcriberSource, + ...transcriberBuildArgs.map((arg) => arg === '/supercmd-swift-module-cache' ? moduleCacheDir : arg), ]); } +function newestMtimeMs(filePath) { + const stats = lstatSync(filePath); + if (stats.isSymbolicLink()) return stats.mtimeMs; + if (!stats.isDirectory()) return stats.mtimeMs; + + return readdirSync(filePath).reduce((newest, entry) => { + return Math.max(newest, newestMtimeMs(path.join(filePath, entry))); + }, stats.mtimeMs); +} + +function transcriberStampPayload() { + return { + version: stampVersion, + whisperVersion, + frameworkUrl, + frameworkSha256, + command: ['swiftc', ...transcriberBuildArgs], + source: { + path: transcriberSource, + mtimeMs: statSync(transcriberSource).mtimeMs, + size: statSync(transcriberSource).size, + }, + script: { + path: __filename, + mtimeMs: statSync(__filename).mtimeMs, + size: statSync(__filename).size, + }, + framework: { + path: frameworkDir, + mtimeMs: newestMtimeMs(frameworkDir), + }, + }; +} + +function readStamp() { + try { + return JSON.parse(readFileSync(transcriberStamp, 'utf8')); + } catch { + return null; + } +} + +function transcriberIsUpToDate() { + if (!existsSync(transcriberBinary) || !existsSync(transcriberSource) || !existsSync(frameworkDir)) return false; + + const payload = transcriberStampPayload(); + if (JSON.stringify(readStamp()) !== JSON.stringify(payload)) return false; + + const binaryMtime = statSync(transcriberBinary).mtimeMs; + return payload.source.mtimeMs <= binaryMtime && payload.framework.mtimeMs <= binaryMtime; +} + +function writeTranscriberStamp() { + writeFileSync(transcriberStamp, JSON.stringify(transcriberStampPayload(), null, 2)); +} + try { // Verify the framework actually has the whisper module, not just an empty directory const moduleMapPath = path.join(frameworkDir, 'Modules', 'module.modulemap'); @@ -104,7 +165,12 @@ try { } else { console.log('[whisper.cpp] Using existing macOS framework'); } - buildTranscriber(); + if (transcriberIsUpToDate()) { + console.log('[whisper.cpp] whisper-transcriber is up to date, skipping rebuild'); + } else { + buildTranscriber(); + writeTranscriberStamp(); + } console.log('[whisper.cpp] Ready'); } catch (error) { console.error('[whisper.cpp] Build failed:', error instanceof Error ? error.message : error); diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index d6835fe5..780ac61c 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -3,7 +3,11 @@ import path from 'path'; const localesDir = path.resolve('src/renderer/src/i18n/locales'); const baseLocale = 'en'; -const strictLocales = new Set((process.env.SUPERCMD_STRICT_LOCALES || 'ko').split(',').map((item) => item.trim()).filter(Boolean)); +const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`).sort(); +const defaultStrictLocales = localeFiles.map((file) => file.replace(/\.json$/, '')); +const strictLocales = new Set( + (process.env.SUPERCMD_STRICT_LOCALES || defaultStrictLocales.join(',')).split(',').map((item) => item.trim()).filter(Boolean), +); function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -41,7 +45,6 @@ function walk(baseNode, localeNode, currentPath, result) { } const baseMessages = JSON.parse(fs.readFileSync(path.join(localesDir, `${baseLocale}.json`), 'utf8')); -const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`); let hasStrictFailure = false; diff --git a/scripts/check-package-manager-parity.mjs b/scripts/check-package-manager-parity.mjs new file mode 100644 index 00000000..8343751e --- /dev/null +++ b/scripts/check-package-manager-parity.mjs @@ -0,0 +1,154 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const EXPECTED_PACKAGE_MANAGER = "npm@11.12.1"; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function packagePathForLockPath(rootDir, lockPath) { + return path.join(rootDir, lockPath, "package.json"); +} + +function collectDependencySpecFailures(packageJson, packageLock) { + const failures = []; + const rootLockPackage = packageLock.packages?.[""]; + const sections = ["dependencies", "devDependencies", "optionalDependencies"]; + + if (!rootLockPackage) { + return ["package-lock.json is missing packages[\"\"], so root dependency specs cannot be validated."]; + } + + for (const section of sections) { + const packageSpecs = packageJson[section] ?? {}; + const lockSpecs = rootLockPackage[section] ?? {}; + const dependencyNames = new Set([...Object.keys(packageSpecs), ...Object.keys(lockSpecs)]); + + for (const dependencyName of dependencyNames) { + if (packageSpecs[dependencyName] !== lockSpecs[dependencyName]) { + failures.push( + `${section}.${dependencyName} is ${JSON.stringify(packageSpecs[dependencyName])} in package.json but ${JSON.stringify(lockSpecs[dependencyName])} in package-lock.json.`, + ); + } + } + } + + return failures; +} + +function collectInstalledVersionFailures(rootDir, packageLock) { + const failures = []; + let checkedInstalledPackages = 0; + + for (const [lockPath, packageEntry] of Object.entries(packageLock.packages ?? {})) { + if (!lockPath.startsWith("node_modules/") || !packageEntry.version) { + continue; + } + + const installedPackageJsonPath = packagePathForLockPath(rootDir, lockPath); + if (!fs.existsSync(installedPackageJsonPath)) { + continue; + } + + const installedPackageJson = readJson(installedPackageJsonPath); + checkedInstalledPackages += 1; + + if (installedPackageJson.version !== packageEntry.version) { + failures.push( + `${lockPath} is installed at ${installedPackageJson.version}, but package-lock.json expects ${packageEntry.version}.`, + ); + } + } + + return { failures, checkedInstalledPackages }; +} + +function isPnpmRuntime(env) { + const markers = [ + env.npm_config_user_agent, + env.npm_execpath, + env.npm_node_execpath, + env.npm_config_userconfig, + ].filter(Boolean); + + return markers.some((marker) => /(^|[/\s])pnpm(?:[/@\s.]|$)/i.test(marker)); +} + +export function checkPackageManagerParity({ rootDir = process.cwd(), env = process.env } = {}) { + const failures = []; + const packageJsonPath = path.join(rootDir, "package.json"); + const packageLockPath = path.join(rootDir, "package-lock.json"); + const pnpmLockPath = path.join(rootDir, "pnpm-lock.yaml"); + + if (!fs.existsSync(packageJsonPath)) { + failures.push("package.json is missing."); + } + + if (!fs.existsSync(packageLockPath)) { + failures.push("package-lock.json is missing; validation must use the committed npm lockfile."); + } + + if (fs.existsSync(pnpmLockPath)) { + failures.push("pnpm-lock.yaml exists at the repository root; remove it before running validation."); + } + + if (isPnpmRuntime(env)) { + failures.push("validation is running under pnpm; use a real npm binary with package-lock.json instead."); + } + + if (failures.length > 0 || !fs.existsSync(packageJsonPath) || !fs.existsSync(packageLockPath)) { + return { ok: false, failures, checkedInstalledPackages: 0 }; + } + + const packageJson = readJson(packageJsonPath); + const packageLock = readJson(packageLockPath); + + if (packageJson.packageManager !== EXPECTED_PACKAGE_MANAGER) { + failures.push( + `package.json packageManager must be ${EXPECTED_PACKAGE_MANAGER}, found ${JSON.stringify(packageJson.packageManager)}.`, + ); + } + + failures.push(...collectDependencySpecFailures(packageJson, packageLock)); + + const { failures: installedVersionFailures, checkedInstalledPackages } = collectInstalledVersionFailures( + rootDir, + packageLock, + ); + failures.push(...installedVersionFailures); + + return { + ok: failures.length === 0, + failures, + checkedInstalledPackages, + }; +} + +export function formatPackageManagerParityResult(result) { + if (result.ok) { + return `package-manager parity ok: package-lock.json is authoritative; checked ${result.checkedInstalledPackages} installed package version(s).`; + } + + return [ + "Package manager parity check failed:", + ...result.failures.map((failure) => `- ${failure}`), + "", + "Use a real npm binary and `npm ci` to restore package-lock.json parity. Do not commit pnpm-lock.yaml.", + ].join("\n"); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + const result = checkPackageManagerParity(); + const message = formatPackageManagerParityResult(result); + + if (result.ok) { + console.log(message); + } else { + console.error(message); + process.exitCode = 1; + } +} diff --git a/scripts/ensure-macos-runtime-deps.mjs b/scripts/ensure-macos-runtime-deps.mjs new file mode 100644 index 00000000..10dd167d --- /dev/null +++ b/scripts/ensure-macos-runtime-deps.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; + +if (process.platform !== 'darwin') { + process.exit(0); +} + +const require = createRequire(import.meta.url); +const REQUIRED_PACKAGES = ['electron-liquid-glass']; + +let missing = 0; + +for (const packageName of REQUIRED_PACKAGES) { + try { + require(packageName); + } catch (error) { + missing++; + console.error(`ensure-macos-runtime-deps: failed to load ${packageName}.`); + console.error(error); + } +} + +if (missing > 0) { + process.exit(1); +} + +console.log('ensure-macos-runtime-deps: macOS runtime packages are available.'); diff --git a/scripts/file-search-perf-harness.mjs b/scripts/file-search-perf-harness.mjs new file mode 100644 index 00000000..fb6ae292 --- /dev/null +++ b/scripts/file-search-perf-harness.mjs @@ -0,0 +1,561 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import { importTs } from './lib/ts-import.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const FILE_SEARCH_INDEX_TS = path.join(REPO_ROOT, 'src/main/file-search-index.ts'); + +const DEFAULT_CONFIG = { + projects: 24, + modulesPerProject: 4, + filesPerModule: 8, + queryRuns: 5, + updateCount: 32, + deleteCount: 32, + limit: 12, + keep: false, + json: false, + output: '', + includeProtectedHomeRoots: true, + thresholds: {}, +}; + +const FILE_NAME_PATTERNS = [ + 'command-palette-{project}-{module}-{file}.ts', + 'launch-plan-alpha-{project}-{module}-{file}.md', + 'invoice-report-{project}-{module}-{file}.json', + 'notes-search-index-{project}-{module}-{file}.txt', + 'shortcut-resolver-{project}-{module}-{file}.tsx', + 'settings-migration-{project}-{module}-{file}.ts', + 'quick-action-workflow-{project}-{module}-{file}.md', + 'browser-history-cache-{project}-{module}-{file}.json', +]; + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function formatPattern(pattern, values) { + return pattern + .replaceAll('{project}', values.project) + .replaceAll('{module}', values.module) + .replaceAll('{file}', values.file); +} + +function readNumber(value, fallback, min = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function readOptionalNumber(value) { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function getArgValue(args, name) { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + if (index >= 0 && index + 1 < args.length) return args[index + 1]; + return undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function getUsage() { + return [ + 'Usage: node scripts/file-search-perf-harness.mjs [options]', + '', + 'Options:', + ' --projects Number of synthetic projects', + ' --modules Modules per project', + ' --files-per-module Files per module', + ' --query-runs Query repetitions per query', + ' --updates Watcher-style added paths', + ' --deletes Deleted paths in the tombstone batch', + ' --limit Search result limit', + ' --json Print JSON output', + ' --output Write the same output to a file', + ' --keep Keep the temp fixture for inspection', + ' --threshold-index-ms Fail if initial indexing exceeds n ms', + ' --threshold-normal-query-p95-ms Fail if normal query p95 exceeds n ms', + ' --threshold-path-query-p95-ms Fail if path-like query p95 exceeds n ms', + ' --threshold-event-loop-lag-p95-ms Fail if measured event-loop lag p95 exceeds n ms', + ' --threshold-watch-update-ms Fail if watcher-style batch exceeds n ms', + ' --threshold-delete-ms Fail if delete batch exceeds n ms', + ].join('\n'); +} + +export function parseFileSearchPerfHarnessArgs(args = process.argv.slice(2)) { + return { + projects: readNumber(getArgValue(args, '--projects'), DEFAULT_CONFIG.projects), + modulesPerProject: readNumber(getArgValue(args, '--modules'), DEFAULT_CONFIG.modulesPerProject), + filesPerModule: readNumber(getArgValue(args, '--files-per-module'), DEFAULT_CONFIG.filesPerModule), + queryRuns: readNumber(getArgValue(args, '--query-runs'), DEFAULT_CONFIG.queryRuns), + updateCount: readNumber(getArgValue(args, '--updates'), DEFAULT_CONFIG.updateCount), + deleteCount: readNumber(getArgValue(args, '--deletes'), DEFAULT_CONFIG.deleteCount), + limit: readNumber(getArgValue(args, '--limit'), DEFAULT_CONFIG.limit), + keep: hasFlag(args, '--keep'), + json: hasFlag(args, '--json'), + output: getArgValue(args, '--output') || '', + includeProtectedHomeRoots: !hasFlag(args, '--exclude-protected-home-roots'), + thresholds: { + initialIndexMs: readOptionalNumber(getArgValue(args, '--threshold-index-ms')), + normalQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-normal-query-p95-ms')), + pathQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-path-query-p95-ms')), + eventLoopLagP95Ms: readOptionalNumber(getArgValue(args, '--threshold-event-loop-lag-p95-ms')), + watchUpdateBatchMs: readOptionalNumber(getArgValue(args, '--threshold-watch-update-ms')), + deleteBatchMs: readOptionalNumber(getArgValue(args, '--threshold-delete-ms')), + }, + }; +} + +function mergeConfig(overrides = {}) { + return { + ...DEFAULT_CONFIG, + ...overrides, + thresholds: { + ...DEFAULT_CONFIG.thresholds, + ...(overrides.thresholds || {}), + }, + }; +} + +async function writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function createSyntheticHome(config) { + const tempRootRaw = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-file-search-perf-')); + const tempRoot = await fs.realpath(tempRootRaw); + const homeDir = path.join(tempRoot, 'home'); + const benchRoot = path.join(homeDir, 'BenchRoot'); + const projectsRoot = path.join(benchRoot, 'Projects'); + const archiveRoot = path.join(benchRoot, 'Archive'); + const referenceRoot = path.join(benchRoot, 'References'); + const filePaths = []; + const deleteCandidates = []; + + await fs.mkdir(projectsRoot, { recursive: true }); + await fs.mkdir(archiveRoot, { recursive: true }); + await fs.mkdir(referenceRoot, { recursive: true }); + + for (let projectIndex = 0; projectIndex < config.projects; projectIndex += 1) { + const project = `project-${pad(projectIndex)}`; + for (let moduleIndex = 0; moduleIndex < config.modulesPerProject; moduleIndex += 1) { + const moduleName = `module-${pad(moduleIndex, 2)}`; + const srcDir = path.join(projectsRoot, project, moduleName, 'src'); + const docsDir = path.join(projectsRoot, project, moduleName, 'docs'); + + for (let fileIndex = 0; fileIndex < config.filesPerModule; fileIndex += 1) { + const file = pad(fileIndex, 2); + const pattern = FILE_NAME_PATTERNS[fileIndex % FILE_NAME_PATTERNS.length]; + const targetDir = fileIndex % 2 === 0 ? srcDir : docsDir; + const fileName = formatPattern(pattern, { project, module: moduleName, file }); + const filePath = path.join(targetDir, fileName); + await writeFile( + filePath, + [ + `project=${project}`, + `module=${moduleName}`, + `file=${file}`, + `intent=${fileName}`, + '', + ].join('\n') + ); + filePaths.push(filePath); + if (fileName.startsWith('launch-plan-alpha') || fileName.startsWith('invoice-report')) { + deleteCandidates.push(filePath); + } + } + } + + await writeFile( + path.join(archiveRoot, `release-notes-command-palette-${project}.md`), + `release notes for ${project}\n` + ); + await writeFile( + path.join(referenceRoot, `path-query-reference-${project}.txt`), + `path reference for ${project}\n` + ); + } + + return { + tempRoot, + homeDir, + benchRoot, + projectsRoot, + filePaths, + deleteCandidates, + }; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1)); + return sorted[index]; +} + +function summarizeDurations(samples) { + const totalMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0); + const durations = samples.map((sample) => sample.durationMs); + return { + runs: samples.length, + minMs: Number(Math.min(...durations).toFixed(3)), + meanMs: Number((totalMs / Math.max(1, samples.length)).toFixed(3)), + medianMs: Number(percentile(durations, 50).toFixed(3)), + p95Ms: Number(percentile(durations, 95).toFixed(3)), + maxMs: Number(Math.max(...durations).toFixed(3)), + totalResults: samples.reduce((sum, sample) => sum + sample.resultCount, 0), + }; +} + +function summarizeLag(samples) { + if (samples.length === 0) { + return { + samples: 0, + p95Ms: 0, + maxMs: 0, + }; + } + return { + samples: samples.length, + p95Ms: Number(percentile(samples, 95).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)), + }; +} + +async function measureEventLoopLagDuring(fn, intervalMs = 10) { + const samples = []; + let expectedAt = performance.now() + intervalMs; + const timer = setInterval(() => { + const now = performance.now(); + samples.push(Math.max(0, now - expectedAt)); + expectedAt = now + intervalMs; + }, intervalMs); + timer.unref?.(); + + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + const result = await fn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { + result, + eventLoopLag: summarizeLag(samples), + }; + } finally { + clearInterval(timer); + } +} + +async function measureDuration(fn) { + const startedAt = performance.now(); + const measured = await measureEventLoopLagDuring(fn); + return { + durationMs: performance.now() - startedAt, + result: measured.result, + eventLoopLag: measured.eventLoopLag, + }; +} + +function summarizeEventLoopDelay(histogram) { + return { + meanMs: Number((histogram.mean / 1_000_000).toFixed(3)), + maxMs: Number((histogram.max / 1_000_000).toFixed(3)), + p95Ms: Number((histogram.percentile(95) / 1_000_000).toFixed(3)), + }; +} + +async function measureDurationWithEventLoopDelay(fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + await new Promise((resolve) => setImmediate(resolve)); + const measured = await measureDuration(fn); + await new Promise((resolve) => setImmediate(resolve)); + histogram.disable(); + return { + ...measured, + eventLoopDelay: summarizeEventLoopDelay(histogram), + }; +} + +async function measureQueries(searchIndexedFiles, queries, config) { + const samples = []; + const lagMeasured = await measureEventLoopLagDuring(async () => { + for (let run = 0; run < config.queryRuns; run += 1) { + for (const query of queries) { + const measured = await measureDuration(() => searchIndexedFiles(query, { limit: config.limit })); + samples.push({ + query, + run, + durationMs: measured.durationMs, + resultCount: measured.result.length, + eventLoopLagP95Ms: measured.eventLoopLag.p95Ms, + }); + } + } + }); + return { + ...summarizeDurations(samples), + eventLoopLag: lagMeasured.eventLoopLag, + samples: samples.map((sample) => ({ + ...sample, + durationMs: Number(sample.durationMs.toFixed(3)), + })), + }; +} + +function buildQueries(homeDir) { + return { + normal: [ + 'command palette', + 'launch plan alpha', + 'invoice report', + 'notes search index', + ], + pathLike: [ + '~/BenchRoot/Projects/project-', + '~/BenchRoot/Archive/release-notes-command', + `${homeDir}/BenchRoot/References/path-query-reference`, + ], + }; +} + +async function createUpdateFiles(fixture, config) { + const updatePaths = []; + for (let index = 0; index < config.updateCount; index += 1) { + const project = `project-${pad(index % config.projects)}`; + const moduleName = `module-${pad(index % config.modulesPerProject, 2)}`; + const filePath = path.join( + fixture.projectsRoot, + project, + moduleName, + 'src', + `watcher-added-command-${pad(index)}.ts` + ); + await writeFile(filePath, `watcher added command ${index}\n`); + updatePaths.push(filePath); + } + return updatePaths; +} + +async function deleteFixtureFiles(fixture, config) { + const deletePaths = fixture.deleteCandidates.slice(0, config.deleteCount); + for (const filePath of deletePaths) { + await fs.rm(filePath, { force: true }); + } + return deletePaths; +} + +function evaluateThresholds(metrics, thresholds = {}) { + const checks = [ + ['initialIndexMs', metrics.initialIndexMs, thresholds.initialIndexMs], + ['normalQueryP95Ms', metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms], + ['pathQueryP95Ms', metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms], + ['eventLoopLagP95Ms', metrics.eventLoopLag.p95Ms, thresholds.eventLoopLagP95Ms], + ['watchUpdateBatchMs', metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs], + ['deleteBatchMs', metrics.deleteBatchMs, thresholds.deleteBatchMs], + ]; + const failures = checks + .filter(([, actual, threshold]) => typeof threshold === 'number' && actual > threshold) + .map(([name, actual, threshold]) => `${name} ${actual.toFixed(3)}ms exceeded ${threshold}ms`); + + return { + passed: failures.length === 0, + failures, + applied: Object.fromEntries( + checks + .filter(([, , threshold]) => typeof threshold === 'number') + .map(([name, , threshold]) => [name, threshold]) + ), + }; +} + +function roundMetric(value) { + return Number(value.toFixed(3)); +} + +function toDisplayLines(summary) { + const thresholdLine = summary.thresholds.applied && Object.keys(summary.thresholds.applied).length > 0 + ? summary.thresholds.passed + ? 'Thresholds: passed' + : `Thresholds: failed (${summary.thresholds.failures.join('; ')})` + : 'Thresholds: not applied'; + + return [ + 'File search perf harness', + `Home fixture: ${summary.fixture.homeDir}`, + `Indexed entries: ${summary.fixture.initialEntryCount}`, + `Initial indexing: ${summary.metrics.initialIndexMs.toFixed(3)}ms (event-loop delay p95 ${summary.metrics.initialIndexEventLoopDelay.p95Ms.toFixed(3)}ms, max ${summary.metrics.initialIndexEventLoopDelay.maxMs.toFixed(3)}ms)`, + `Normal queries: mean ${summary.metrics.normalQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.normalQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.normalQueries.totalResults}`, + `Path-like queries: mean ${summary.metrics.pathLikeQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.pathLikeQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.pathLikeQueries.totalResults}`, + `Event-loop lag: p95 ${summary.metrics.eventLoopLag.p95Ms.toFixed(3)}ms, max ${summary.metrics.eventLoopLag.maxMs.toFixed(3)}ms`, + `Watcher-style updates: ${summary.metrics.watchUpdateBatchMs.toFixed(3)}ms for ${summary.fixture.updateCount} paths`, + `Delete batch: ${summary.metrics.deleteBatchMs.toFixed(3)}ms for ${summary.fixture.deleteCount} paths`, + `Post-update query: ${summary.metrics.postUpdateQueryMs.toFixed(3)}ms (${summary.verification.postUpdateResultCount} results)`, + `Deleted exact matches after tombstone: ${summary.verification.deletedExactMatches}`, + thresholdLine, + ]; +} + +export async function runFileSearchPerfHarness(overrides = {}) { + const config = mergeConfig(overrides); + const fixture = await createSyntheticHome(config); + let fileSearchIndexPerfHarness = null; + let summary = null; + let cleanupCompleted = false; + try { + const fileSearchIndex = await importTs(FILE_SEARCH_INDEX_TS); + const { + __fileSearchIndexPerfHarness, + getFileSearchIndexStatus, + searchIndexedFiles, + } = fileSearchIndex; + fileSearchIndexPerfHarness = __fileSearchIndexPerfHarness; + + if (!fileSearchIndexPerfHarness) { + throw new Error('Missing __fileSearchIndexPerfHarness export from file-search-index.ts'); + } + + const queries = buildQueries(fixture.homeDir); + const initialIndex = await measureDurationWithEventLoopDelay(() => + fileSearchIndexPerfHarness.rebuild({ + homeDir: fixture.homeDir, + includeProtectedHomeRoots: config.includeProtectedHomeRoots, + }) + ); + const statusAfterIndex = getFileSearchIndexStatus(); + + const normalQueries = await measureQueries(searchIndexedFiles, queries.normal, config); + const pathLikeQueries = await measureQueries(searchIndexedFiles, queries.pathLike, config); + + const updatePaths = await createUpdateFiles(fixture, config); + const watchUpdate = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(updatePaths) + ); + const postUpdateQuery = await measureDuration(() => + searchIndexedFiles('watcher added command', { limit: config.limit }) + ); + + const deletePaths = await deleteFixtureFiles(fixture, config); + const deleteBatch = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(deletePaths) + ); + const deletedNeedle = deletePaths[0] ? path.basename(deletePaths[0]) : ''; + const postDeleteQuery = await measureDuration(() => + deletedNeedle ? searchIndexedFiles(deletedNeedle, { limit: config.limit }) : Promise.resolve([]) + ); + const deletedExactMatches = deletedNeedle + ? postDeleteQuery.result.filter((result) => result.name === deletedNeedle).length + : 0; + + const metrics = { + initialIndexMs: roundMetric(initialIndex.durationMs), + initialIndexEventLoopDelay: initialIndex.eventLoopDelay, + normalQueries, + pathLikeQueries, + watchUpdateBatchMs: roundMetric(watchUpdate.durationMs), + postUpdateQueryMs: roundMetric(postUpdateQuery.durationMs), + deleteBatchMs: roundMetric(deleteBatch.durationMs), + postDeleteQueryMs: roundMetric(postDeleteQuery.durationMs), + }; + const eventLoopLagSamples = [ + initialIndex.eventLoopLag, + normalQueries.eventLoopLag, + pathLikeQueries.eventLoopLag, + watchUpdate.eventLoopLag, + postUpdateQuery.eventLoopLag, + deleteBatch.eventLoopLag, + postDeleteQuery.eventLoopLag, + ]; + metrics.eventLoopLag = { + p95Ms: Math.max(...eventLoopLagSamples.map((sample) => sample.p95Ms)), + maxMs: Math.max(...eventLoopLagSamples.map((sample) => sample.maxMs)), + samples: eventLoopLagSamples.reduce((sum, sample) => sum + sample.samples, 0), + }; + const realTempDir = await fs.realpath(os.tmpdir()); + + summary = { + config: { + projects: config.projects, + modulesPerProject: config.modulesPerProject, + filesPerModule: config.filesPerModule, + queryRuns: config.queryRuns, + updateCount: config.updateCount, + deleteCount: config.deleteCount, + limit: config.limit, + }, + fixture: { + tempRoot: fixture.tempRoot, + homeDir: fixture.homeDir, + initialFileCount: fixture.filePaths.length + config.projects * 2, + initialEntryCount: statusAfterIndex.indexedEntryCount, + updateCount: updatePaths.length, + deleteCount: deletePaths.length, + }, + metrics, + verification: { + postUpdateResultCount: postUpdateQuery.result.length, + postDeleteResultCount: postDeleteQuery.result.length, + deletedExactMatches, + homeDirectoryUsed: statusAfterIndex.homeDirectory, + homeIsTempDirectory: fixture.homeDir.startsWith(realTempDir), + }, + thresholds: evaluateThresholds(metrics, config.thresholds), + cleanup: { + keptFixture: Boolean(config.keep), + completed: false, + }, + }; + + return summary; + } finally { + fileSearchIndexPerfHarness?.reset(); + if (!config.keep) { + await fs.rm(fixture.tempRoot, { recursive: true, force: true }); + cleanupCompleted = true; + } + if (summary) { + summary.cleanup.completed = cleanupCompleted; + } + } +} + +async function runCli() { + if (hasFlag(process.argv, '--help')) { + process.stdout.write(`${getUsage()}\n`); + return; + } + + const config = parseFileSearchPerfHarnessArgs(); + const summary = await runFileSearchPerfHarness(config); + const output = config.json ? `${JSON.stringify(summary, null, 2)}\n` : `${toDisplayLines(summary).join('\n')}\n`; + + if (config.output) { + await fs.writeFile(path.resolve(config.output), output); + } + process.stdout.write(output); + + if (!summary.thresholds.passed) { + process.exitCode = 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..7afd0d1e --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,219 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeChildProcessMockSource(childProcessKey) { + return ` + const mock = globalThis[${JSON.stringify(childProcessKey)}]; + + export function spawn(...args) { + return mock.spawn(...args); + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const statSync = realFs.statSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + statSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, + mockChildProcess = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const childProcessKey = `__supercmdScriptCommandChildProcess_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + const childProcess = { + spawn() { + throw new Error('Unexpected child_process.spawn call'); + }, + }; + globalThis[metricsKey] = metrics; + if (mockChildProcess) { + globalThis[childProcessKey] = childProcess; + } + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (mockChildProcess) { + plugins.push({ + name: 'supercmd-script-command-child-process-mock', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^child_process$/ }, () => ({ + path: 'child-process-mock', + namespace: 'supercmd-child-process', + })); + pluginBuild.onLoad({ filter: /^child-process-mock$/, namespace: 'supercmd-child-process' }, () => ({ + contents: makeChildProcessMockSource(childProcessKey), + loader: 'js', + })); + }, + }); + } + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + childProcess, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..89523936 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,293 @@ -// Import a self-contained TypeScript module from a test by transpiling it with -// esbuild on the fly. This lets the recovery tests run the *real* production -// source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. -// -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. - -import { transform } from 'esbuild'; -import fs from 'node:fs'; - -export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +// Import a TypeScript/TSX module from a test by bundling it with esbuild on the +// fly. Bundling lets tests execute selected production modules that have local +// imports instead of falling back to grep-only assertions. + +import { build } from 'esbuild'; +import path from 'node:path'; + +let importNonce = 0; + +const DEFAULT_STUB_MODULES = new Set([ + '@phosphor-icons/react', + '@raycast/api', + 'electron', + 'electron-liquid-glass', + 'lucide-react', + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', +]); + +const ASSET_RE = /\.(?:css|less|sass|scss|svg|png|jpe?g|gif|webp|ico|icns|woff2?|ttf|otf|eot)$/i; + +export async function importTs(absPath, options = {}) { + const resolvedPath = path.resolve(absPath); + const { code } = await bundleTs(resolvedPath, options); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(code).toString('base64'), + `#${encodeURIComponent(resolvedPath)}-${importNonce++}`, + ].join(''); return import(dataUrl); } + +export async function bundleTs(absPath, options = {}) { + const result = await build({ + absWorkingDir: options.root || process.cwd(), + entryPoints: [path.resolve(absPath)], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: options.target || 'node20', + sourcemap: options.sourcemap || false, + logLevel: 'silent', + banner: options.browserGlobals === false ? undefined : { js: BROWSER_GLOBALS_BANNER }, + define: { + 'process.env.NODE_ENV': '"test"', + ...(options.define || {}), + }, + loader: { + '.js': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + '.json': 'json', + ...(options.loader || {}), + }, + external: options.external || [], + plugins: [ + tsImportStubPlugin(options), + ...(options.plugins || []), + ], + }); + + return { code: result.outputFiles[0].text }; +} + +function tsImportStubPlugin(options) { + const stubModules = new Set([...DEFAULT_STUB_MODULES, ...Object.keys(options.stubs || {})]); + for (const specifier of options.stubModules || []) stubModules.add(specifier); + + return { + name: 'ts-import-test-stubs', + setup(esbuild) { + esbuild.onResolve({ filter: /.*/ }, (args) => { + if (stubModules.has(args.path) || ASSET_RE.test(args.path)) { + return { path: args.path, namespace: 'ts-import-stub' }; + } + return null; + }); + + esbuild.onLoad({ filter: /.*/, namespace: 'ts-import-stub' }, (args) => ({ + contents: options.stubs?.[args.path] || defaultStubFor(args.path), + loader: 'js', + })); + }, + }; +} + +function defaultStubFor(specifier) { + if (specifier === 'electron') return ELECTRON_STUB; + if (specifier === 'react') return REACT_STUB; + if (specifier === 'react/jsx-runtime' || specifier === 'react/jsx-dev-runtime') return JSX_RUNTIME_STUB; + if (specifier === 'react-dom' || specifier === 'react-dom/client') return REACT_DOM_STUB; + if (ASSET_RE.test(specifier)) return ASSET_STUB; + return GENERIC_PROXY_STUB; +} + +const BROWSER_GLOBALS_BANNER = ` +const __scNoop = () => {}; +const __scMakeStorage = () => { + const map = new Map(); + return { + getItem: (key) => map.has(String(key)) ? map.get(String(key)) : null, + setItem: (key, value) => { map.set(String(key), String(value)); }, + removeItem: (key) => { map.delete(String(key)); }, + clear: () => { map.clear(); }, + }; +}; +var document = globalThis.document || { + addEventListener: __scNoop, + removeEventListener: __scNoop, + createElement: () => ({ style: {}, setAttribute: __scNoop, appendChild: __scNoop, remove: __scNoop }), + body: { appendChild: __scNoop, removeChild: __scNoop }, +}; +var navigator = globalThis.navigator || { platform: 'test', userAgent: 'node' }; +var localStorage = globalThis.localStorage || __scMakeStorage(); +var sessionStorage = globalThis.sessionStorage || __scMakeStorage(); +var window = globalThis.window || { + document, + navigator, + localStorage, + sessionStorage, + addEventListener: __scNoop, + removeEventListener: __scNoop, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload: __scNoop }, +}; +var requestAnimationFrame = globalThis.requestAnimationFrame || ((callback) => setTimeout(() => callback(Date.now()), 0)); +var cancelAnimationFrame = globalThis.cancelAnimationFrame || ((id) => clearTimeout(id)); +`; + +const ELECTRON_STUB = ` +const noop = () => {}; +const asyncNoop = async () => undefined; +const home = process.env.HOME || process.cwd(); +const paths = { + appData: process.cwd(), + desktop: home + '/Desktop', + documents: home + '/Documents', + downloads: home + '/Downloads', + home, + temp: process.env.TMPDIR || process.cwd(), + userData: process.env.SUPERCMD_TEST_USER_DATA || process.cwd(), +}; +const app = { + isPackaged: false, + getAppPath: () => process.cwd(), + getName: () => 'SuperCmd Test', + getPath: (name) => paths[name] || process.cwd(), + getVersion: () => '0.0.0-test', + isReady: () => true, + whenReady: asyncNoop, + on: noop, + once: noop, + quit: noop, + relaunch: noop, + requestSingleInstanceLock: () => true, + setAsDefaultProtocolClient: () => true, +}; +class BrowserWindow { + constructor() { + this.webContents = { + executeJavaScript: asyncNoop, + on: noop, + once: noop, + send: noop, + setWindowOpenHandler: noop, + }; + } + loadFile() { return Promise.resolve(); } + loadURL() { return Promise.resolve(); } + on() {} + once() {} + show() {} + hide() {} + close() {} + destroy() {} + isDestroyed() { return false; } +} +const ipcMain = { handle: noop, on: noop, once: noop, removeAllListeners: noop, removeHandler: noop }; +const ipcRenderer = { invoke: asyncNoop, send: noop, on: noop, once: noop, removeAllListeners: noop, removeListener: noop }; +const shell = { openExternal: asyncNoop, openPath: async () => '', showItemInFolder: noop }; +const dialog = { + showErrorBox: noop, + showMessageBox: async () => ({ response: 0 }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showSaveDialog: async () => ({ canceled: true, filePath: '' }), +}; +const clipboard = { + readText: () => '', + writeText: noop, + readImage: () => ({ isEmpty: () => true, toDataURL: () => '' }), + writeImage: noop, +}; +const nativeTheme = { shouldUseDarkColors: false, on: noop, removeListener: noop }; +const screen = { + getAllDisplays: () => [], + getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1024, height: 768 }, workArea: { x: 0, y: 0, width: 1024, height: 768 }, scaleFactor: 1 }), +}; +const safeStorage = { + decryptString: (value) => Buffer.from(value).toString('utf8'), + encryptString: (value) => Buffer.from(String(value)), + isEncryptionAvailable: () => false, +}; +module.exports = { app, BrowserWindow, clipboard, dialog, ipcMain, ipcRenderer, nativeTheme, safeStorage, screen, shell }; +`; + +const REACT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: noop, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const REACT_DOM_STUB = ` +const root = { render() {}, unmount() {} }; +module.exports = { + createPortal: (children) => children, + createRoot: () => root, + hydrateRoot: () => root, + render() {}, + unmountComponentAtNode() {}, +}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const ASSET_STUB = ` +module.exports = ''; +module.exports.default = ''; +`; + +const GENERIC_PROXY_STUB = ` +const makeStub = (name = 'stub') => new Proxy(function stub() {}, { + apply: () => undefined, + construct: () => ({}), + get: (_target, prop) => { + if (prop === '__esModule') return true; + if (prop === Symbol.toStringTag) return 'Module'; + return makeStub(String(prop)); + }, +}); +module.exports = makeStub(); +module.exports.default = module.exports; +`; diff --git a/scripts/measure-canvas-asset-event-loop.mjs b/scripts/measure-canvas-asset-event-loop.mjs new file mode 100644 index 00000000..c2e868bd --- /dev/null +++ b/scripts/measure-canvas-asset-event-loop.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { once } from 'node:events'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; + +const fileSizeMb = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_MB || '32', 10); +const iterations = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_ITERATIONS || '8', 10); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-canvas-asset-delay-')); +const assetPath = path.join(tmpDir, 'asset.bin'); + +function summarize(histogram, elapsedMs) { + return { + elapsedMs: Number(elapsedMs.toFixed(3)), + meanDelayMs: Number((histogram.mean / 1e6).toFixed(3)), + maxDelayMs: Number((histogram.max / 1e6).toFixed(3)), + p95DelayMs: Number((histogram.percentile(95) / 1e6).toFixed(3)), + }; +} + +async function measure(label, fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + const started = performance.now(); + await fn(); + await new Promise((resolve) => setTimeout(resolve, 5)); + const elapsedMs = performance.now() - started; + histogram.disable(); + return { label, ...summarize(histogram, elapsedMs) }; +} + +async function readResponseBody(response) { + const reader = response.body?.getReader(); + if (!reader) return 0; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + } + return bytes; +} + +async function createServingServer(mode) { + const server = http.createServer(async (_req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + + if (mode === 'buffered') { + const data = await fs.promises.readFile(assetPath); + res.end(data); + return; + } + + fs.createReadStream(assetPath).pipe(res); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}/asset.bin`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +async function measureHttpServing(label, mode) { + const server = await createServingServer(mode); + try { + return await measure(label, async () => { + for (let index = 0; index < iterations; index += 1) { + const response = await fetch(server.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + await response.arrayBuffer(); + } + }); + } finally { + await server.close(); + } +} + +try { + fs.writeFileSync(assetPath, Buffer.alloc(fileSizeMb * 1024 * 1024, 7)); + + const syncRead = await measure('sync-readFileSync-baseline', async () => { + for (let index = 0; index < iterations; index += 1) { + fs.readFileSync(assetPath); + } + }); + + const asyncRead = await measure('async-fs.promises.readFile-baseline', async () => { + for (let index = 0; index < iterations; index += 1) { + await fs.promises.readFile(assetPath); + } + }); + + const bufferedResponse = await measure('old-buffered-response-readFile', async () => { + for (let index = 0; index < iterations; index += 1) { + const response = new Response(await fs.promises.readFile(assetPath), { + headers: { 'Content-Type': 'application/octet-stream' }, + }); + await readResponseBody(response); + } + }); + + const streamResponse = await measure('new-file-stream-response', async () => { + for (let index = 0; index < iterations; index += 1) { + const stream = fs.createReadStream(assetPath); + const response = new Response(Readable.toWeb(stream), { + headers: { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + await readResponseBody(response); + } + }); + + const bufferedHttp = await measureHttpServing('old-buffered-http-serving', 'buffered'); + const streamHttp = await measureHttpServing('new-stream-http-serving', 'stream'); + + console.log(JSON.stringify({ + fileSizeMb, + iterations, + bytesServedPerMeasurement: fileSizeMb * 1024 * 1024 * iterations, + measurements: [ + syncRead, + asyncRead, + bufferedResponse, + streamResponse, + bufferedHttp, + streamHttp, + ], + }, null, 2)); +} finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +} diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs new file mode 100644 index 00000000..e30db23c --- /dev/null +++ b/scripts/measure-extension-bundle-cache.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node + +import { fileURLToPath, pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { build } from 'esbuild'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const iterations = Number.parseInt(process.env.SUPERCMD_EXTENSION_BUNDLE_MEASURE_ITERATIONS || '20', 10); + +export function createFixture() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-extension-bundle-cache-')); + const userDataDir = path.join(tmpDir, 'userData'); + const extensionsDir = path.join(userDataDir, 'extensions'); + const extDir = path.join(extensionsDir, 'bundle-cache-fixture'); + const buildDir = path.join(extDir, '.sc-build'); + + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: 'bundle-cache-fixture', + title: 'Bundle Cache Fixture', + description: 'Fixture for extension bundle cache measurement', + owner: 'codex', + commands: [ + { + name: 'background', + title: 'Background', + description: 'No-view command', + mode: 'no-view', + preferences: [ + { + name: 'freshCommandPref', + title: 'Fresh Command Pref', + type: 'textfield', + default: 'default-command', + }, + ], + }, + { + name: 'menu', + title: 'Menu', + description: 'Menu-bar command', + mode: 'menu-bar', + }, + ], + preferences: [ + { + name: 'freshExtensionPref', + title: 'Fresh Extension Pref', + type: 'textfield', + default: 'default-extension', + }, + ], + }, + null, + 2 + ) + ); + fs.writeFileSync(path.join(buildDir, 'background.js'), 'module.exports = { name: "background" };\n'); + fs.writeFileSync(path.join(buildDir, 'menu.js'), 'module.exports = { name: "menu" };\n'); + + return { tmpDir, userDataDir, extDir }; +} + +export async function bundleExtensionRunner(userDataDir) { + const outFile = path.join( + os.tmpdir(), + `sc-extension-runner-measure-${process.pid}-${Date.now()}.cjs` + ); + + const stubModule = (filter, source) => ({ + name: `stub-${filter}`, + setup(pluginBuild) { + pluginBuild.onResolve({ filter }, () => ({ + path: filter.source, + namespace: `stub-${filter.source}`, + })); + pluginBuild.onLoad({ filter: /.*/, namespace: `stub-${filter.source}` }, () => ({ + contents: source, + loader: 'js', + })); + }, + }); + + await build({ + entryPoints: [path.join(root, 'src/main/extension-runner.ts')], + outfile: outFile, + bundle: true, + external: ['esbuild'], + platform: 'node', + format: 'cjs', + target: 'node20', + logLevel: 'silent', + plugins: [ + stubModule( + /^electron$/, + ` + module.exports = { + app: { + getPath(name) { + if (name === 'userData') return ${JSON.stringify(userDataDir)}; + return ${JSON.stringify(userDataDir)}; + } + } + }; + ` + ), + stubModule( + /extension-preferences-store$/, + ` + module.exports = { + getExtensionPreferences(extName, cmdName) { + const values = globalThis.__extensionPreferenceValues || {}; + if (cmdName) return values[extName + '/' + cmdName] || {}; + return values[extName] || {}; + } + }; + ` + ), + stubModule( + /settings-store$/, + ` + module.exports = { + loadSettings() { + return { customExtensionFolders: [] }; + } + }; + ` + ), + ], + }); + + return outFile; +} + +export function createCounters(extDir) { + const counters = { + readFileSync: 0, + packageJsonReads: 0, + bundleReads: 0, + jsonParseCalls: 0, + existsSync: 0, + statSync: 0, + readdirSync: 0, + }; + const restore = []; + const packageJsonPath = path.join(extDir, 'package.json'); + const buildDir = path.join(extDir, '.sc-build'); + + const patch = (target, key, wrapper) => { + const original = target[key]; + target[key] = wrapper(original); + restore.push(() => { + target[key] = original; + }); + }; + + patch(fs, 'readFileSync', (original) => function patchedReadFileSync(filePath, ...args) { + const normalizedPath = typeof filePath === 'string' ? filePath : String(filePath); + counters.readFileSync++; + if (path.resolve(normalizedPath) === packageJsonPath) counters.packageJsonReads++; + if (path.resolve(normalizedPath).startsWith(buildDir + path.sep)) counters.bundleReads++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'existsSync', (original) => function patchedExistsSync(filePath) { + counters.existsSync++; + return original.call(this, filePath); + }); + patch(fs, 'statSync', (original) => function patchedStatSync(filePath, ...args) { + counters.statSync++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'readdirSync', (original) => function patchedReaddirSync(filePath, ...args) { + counters.readdirSync++; + return original.call(this, filePath, ...args); + }); + patch(JSON, 'parse', (original) => function patchedJsonParse(source, ...args) { + counters.jsonParseCalls++; + return original.call(this, source, ...args); + }); + + return { + counters, + restore() { + while (restore.length > 0) restore.pop()(); + }, + }; +} + +export async function measure(label, extDir, fn) { + const { counters, restore } = createCounters(extDir); + const started = performance.now(); + try { + await fn(); + } finally { + counters.elapsedMs = Number((performance.now() - started).toFixed(3)); + restore(); + } + return { label, iterations, ...counters }; +} + +async function main() { + const fixture = createFixture(); + let bundledRunner; + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-1' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-1' }, + }; + + const noView = await measure('repeated-getExtensionBundle-no-view', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const bundle = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(bundle.mode, 'no-view'); + assert.equal(bundle.preferences.freshExtensionPref, 'stored-extension-1'); + assert.equal(bundle.preferences.freshCommandPref, 'stored-command-1'); + } + }); + + const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const commands = runner + .discoverInstalledExtensionCommands() + .filter((command) => command.mode === 'menu-bar'); + assert.equal(commands.length, 1); + const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName); + assert.equal(bundle.mode, 'menu-bar'); + } + }); + + console.log(JSON.stringify({ iterations, measurements: [noView, menuBar] }, null, 2)); + } finally { + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/measure-extension-wrapper-cache.mjs b/scripts/measure-extension-wrapper-cache.mjs new file mode 100644 index 00000000..97114635 --- /dev/null +++ b/scripts/measure-extension-wrapper-cache.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +import { performance } from 'node:perf_hooks'; +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 { + clearCompiledExtensionWrapperCache, + EXTENSION_WRAPPER_ARGUMENTS, + getCompiledExtensionWrapper, + getCompiledExtensionWrapperCacheStats, +} = await importTs(path.join(root, 'src/renderer/src/utils/extension-wrapper-cache.ts')); + +const ITERATIONS = Number.parseInt(process.env.SUPERCMD_WRAPPER_MEASURE_ITERATIONS || '1000', 10); + +function patchSchemeDynamicImports(sourceCode) { + return String(sourceCode || '').replace( + /\bimport\(\s*(["'])(swift:[^"']+|rust:[^"']+)\1\s*\)/g, + (_match, quote, specifier) => `__scDynamicImport(${quote}${specifier}${quote})` + ); +} + +function createFixtureExtensionCode() { + const body = ` +Object.defineProperty(exports, "__esModule", { value: true }); +const React = require("react"); +let moduleScopedCounter = 0; +moduleScopedCounter += 1; +const pendingTimer = setTimeout(() => {}, 60000); +exports.default = function FixtureCommand() { + return { + reactVersion: React.version, + moduleScopedCounter, + pendingTimerType: typeof pendingTimer + }; +}; +`; + return body + '\n'.repeat(20) + '// filler '.repeat(250); +} + +function createRunEnv() { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const timers = new Set(); + const trackTimeout = (cb, ms, ...rest) => { + const id = setTimeout(cb, ms, ...rest); + timers.add(id); + return id; + }; + const trackClearTimeout = (id) => { + timers.delete(id); + clearTimeout(id); + }; + const fakeRequire = (name) => { + if (name === 'react') return { version: '18.2.0' }; + return {}; + }; + + return { + args: [ + moduleExports, + fakeRequire, + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalThis, + globalThis, + (fn, ...args) => trackTimeout(() => fn(...args), 0), + trackClearTimeout, + () => 0, + () => {}, + trackTimeout, + trackClearTimeout, + () => 0, + () => {}, + undefined, + async () => ({}), + ], + fakeModule, + cleanup: () => { + timers.forEach((id) => clearTimeout(id)); + timers.clear(); + }, + getTimerCount: () => timers.size, + }; +} + +function measureUncachedLoads(code, iterations) { + let wrapperCreationCount = 0; + let wrapperCreationMs = 0; + let executionMs = 0; + let leakedTimerPeak = 0; + + for (let i = 0; i < iterations; i++) { + const executableCode = patchSchemeDynamicImports(code); + + const compileStart = performance.now(); + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, executableCode); + wrapperCreationMs += performance.now() - compileStart; + wrapperCreationCount++; + + const env = createRunEnv(); + const executionStart = performance.now(); + wrapper(...env.args); + executionMs += performance.now() - executionStart; + leakedTimerPeak = Math.max(leakedTimerPeak, env.getTimerCount()); + env.cleanup(); + + if (typeof env.fakeModule.exports.default !== 'function') { + throw new Error('Fixture did not export a function'); + } + } + + return { + iterations, + wrapperCreationCount, + wrapperCreationMs, + executionMs, + totalLoadMs: wrapperCreationMs + executionMs, + leakedTimerPeak, + }; +} + +function measureCachedLoads(code, iterations) { + clearCompiledExtensionWrapperCache(); + + let cacheLookupMs = 0; + let executionMs = 0; + let leakedTimerPeak = 0; + + for (let i = 0; i < iterations; i++) { + const executableCode = patchSchemeDynamicImports(code); + + const cacheStart = performance.now(); + const { wrapper } = getCompiledExtensionWrapper({ + extensionIdentity: 'fixture-owner/fixture-extension/fixture-command', + code, + executableCode, + }); + cacheLookupMs += performance.now() - cacheStart; + + const env = createRunEnv(); + const executionStart = performance.now(); + wrapper(...env.args); + executionMs += performance.now() - executionStart; + leakedTimerPeak = Math.max(leakedTimerPeak, env.getTimerCount()); + env.cleanup(); + + if (typeof env.fakeModule.exports.default !== 'function') { + throw new Error('Fixture did not export a function'); + } + } + + const stats = getCompiledExtensionWrapperCacheStats(); + return { + iterations, + wrapperCreationCount: stats.wrapperCreationCount, + wrapperCreationMs: stats.wrapperCreationMs, + cacheHits: stats.hits, + cacheMisses: stats.misses, + cacheLookupMs, + executionMs, + totalLoadMs: cacheLookupMs + executionMs, + leakedTimerPeak, + }; +} + +const fixtureCode = createFixtureExtensionCode(); + +console.log(JSON.stringify({ + scenario: 'extension-wrapper-cache', + codeLength: fixtureCode.length, + uncached: measureUncachedLoads(fixtureCode, ITERATIONS), + cached: measureCachedLoads(fixtureCode, ITERATIONS), +}, null, 2)); diff --git a/scripts/measure-icon-resolution.mjs b/scripts/measure-icon-resolution.mjs new file mode 100644 index 00000000..4555ada0 --- /dev/null +++ b/scripts/measure-icon-resolution.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); +const outputFile = path.join(os.tmpdir(), `supercmd-icon-resolution-${process.pid}-${Date.now()}.mjs`); + +const exactInputs = [ + 'AddPerson', + 'Icon.ArrowLeftCircleFilled', + 'MagnifyingGlass', + 'Stopwatch', + 'Temperature', + 'Dot', + 'XMarkCircleFilled', + 'Folder', +]; + +const unknownAndFuzzyInputs = [ + 'TotallyMissingIconName', + 'HyperSpecificSparkleClockWidget', + 'SuperCmdTimerStopwatchShape', + 'UnmappedTerminalCommandLineIcon', + 'MysteryNetworkCloudBolt', + 'Noise___No_Such_Icon_999', +]; + +function exposeResolverPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-resolution-for-measurement', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source}\nexport const __measureResolvePhosphorIconFromRaycast = resolvePhosphorIconFromRaycast;\n`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function stubServerRenderingPlugin() { + return { + name: 'stub-server-rendering-for-measurement', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-measurement-stub', + namespace: 'measurement-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'measurement-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }; +} + +function measureCase(resolveIcon, { name, inputs, iterations }) { + const calls = inputs.length * iterations; + const start = performance.now(); + let resolved = 0; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const input of inputs) { + if (resolveIcon(input)?.icon) resolved += 1; + } + } + + const durationMs = performance.now() - start; + return { + name, + calls, + resolved, + durationMs: Number(durationMs.toFixed(3)), + callsPerMs: Number((calls / Math.max(durationMs, 0.001)).toFixed(3)), + }; +} + +async function main() { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [exposeResolverPlugin(), stubServerRenderingPlugin()], + }); + + const moduleUrl = `${pathToFileURL(outputFile).href}?t=${Date.now()}`; + const runtime = await import(moduleUrl); + const resolveIcon = runtime.__measureResolvePhosphorIconFromRaycast; + if (typeof resolveIcon !== 'function') { + throw new Error('Failed to expose resolvePhosphorIconFromRaycast for measurement.'); + } + + const results = [ + measureCase(resolveIcon, { + name: 'exact/repeated', + inputs: exactInputs, + iterations: 2500, + }), + measureCase(resolveIcon, { + name: 'unknown-fuzzy/repeated', + inputs: unknownAndFuzzyInputs, + iterations: 250, + }), + ]; + + console.log('Icon resolution measurement'); + for (const result of results) { + console.log( + `${result.name}: ${result.calls} calls, ${result.durationMs} ms, ${result.callsPerMs} calls/ms, resolved ${result.resolved}` + ); + } + console.log(JSON.stringify({ results }, null, 2)); +} + +try { + await main(); +} finally { + await fs.unlink(outputFile).catch(() => {}); +} diff --git a/scripts/measure-launcher-command-list-render.mjs b/scripts/measure-launcher-command-list-render.mjs new file mode 100644 index 00000000..5f90920a --- /dev/null +++ b/scripts/measure-launcher-command-list-render.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rowPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandRow.tsx'); +const listPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandList.tsx'); + +const rowCount = readNumberArg('--rows', 5000); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.launcher-command-list-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import LauncherCommandList from ${JSON.stringify(listPath)}; + +type CommandInfo = { + id: string; + title: string; + subtitle?: string; + keywords?: string[]; + iconDataUrl?: string; + iconEmoji?: string; + iconName?: string; + category: 'app' | 'settings' | 'system' | 'extension' | 'script'; + path?: string; + browserResultKind?: 'open-tab' | 'bookmark' | 'history' | 'search'; + browserFaviconUrl?: string; +}; + +type LauncherCommandSection = { + title: string; + items: CommandInfo[]; +}; + +type Metrics = { + rowRenderCount: number; +}; + +declare global { + // eslint-disable-next-line no-var + var __launcherCommandListMetrics: Metrics | undefined; +} + +const rowCount = ${rowCount}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; + +const TRANSLATIONS: Record = { + 'launcher.badges.application': 'Application', + 'launcher.badges.bookmark': 'Bookmark', + 'launcher.badges.extension': 'Extension', + 'launcher.badges.history': 'History', + 'launcher.badges.openTab': 'Open Tab', + 'launcher.badges.quickLink': 'Quick Link', + 'launcher.badges.script': 'Script', + 'launcher.badges.settings': 'System Settings', + 'launcher.categories.browser': 'Browser', + 'launcher.categories.files': 'Files', + 'launcher.categories.recent': 'Recent', + 'launcher.categories.search': 'Search', + 'launcher.sections.pinned': 'Pinned', + 'launcher.sections.results': 'Results', + 'launcher.sections.selectedText': 'Selected Text', + 'launcher.status.discoveringApps': 'Discovering apps...', + 'launcher.status.noMatchingResults': 'No matching results', + 'common.system': 'System', + 'read.title': 'Read', + 'settings.title': 'Settings', + 'whisper.title': 'Whisper', +}; + +function t(key: string): string { + return TRANSLATIONS[key] || key; +} + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function makeCommand(index: number, titlePrefix = 'Command'): CommandInfo { + const kind = index % 9; + const base = { + id: \`measure-command-\${index}\`, + title: \`\${titlePrefix} \${String(index).padStart(5, '0')}\`, + subtitle: index % 4 === 0 ? \`Workspace action \${index}\` : undefined, + keywords: ['measure', 'launcher', String(index)], + }; + if (kind === 0) { + return { ...base, category: 'app', path: \`/Applications/Measure \${index}.app\`, iconDataUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }; + } + if (kind === 1) { + return { ...base, category: 'system', id: \`system-measure-\${index}\` }; + } + if (kind === 2) { + return { ...base, category: 'extension', path: \`measure-extension/command-\${index}\` }; + } + if (kind === 3) { + return { ...base, category: 'script', iconEmoji: '>' }; + } + if (kind === 4) { + return { ...base, category: 'system', browserResultKind: 'history', browserFaviconUrl: 'https://example.com/favicon.ico' }; + } + if (kind === 5) { + return { ...base, category: 'system', browserResultKind: 'open-tab' }; + } + if (kind === 6) { + return { ...base, category: 'system', browserResultKind: 'bookmark' }; + } + if (kind === 7) { + return { ...base, category: 'system', id: \`quicklink-measure-\${index}\`, iconName: 'Link' }; + } + return { ...base, category: 'settings' }; +} + +function makeSections(commands: CommandInfo[], titles: string[]): LauncherCommandSection[] { + const perSection = Math.max(1, Math.ceil(commands.length / titles.length)); + return titles.map((title, sectionIndex) => ({ + title, + items: commands.slice(sectionIndex * perSection, (sectionIndex + 1) * perSection), + })).filter((section) => section.items.length > 0); +} + +function flatten(sections: LauncherCommandSection[]): CommandInfo[] { + return sections.flatMap((section) => section.items); +} + +const rootCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index)); +const queryCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index, 'Query Match')).reverse(); +const narrowedCommands = Array.from({ length: Math.max(1, Math.floor(rowCount * 0.6)) }, (_, index) => makeCommand(index * 2, 'Filtered Match')); + +const rootSections = makeSections(rootCommands, ['', 'Pinned', 'Recent', 'Results', 'Files']); +const querySections = makeSections(queryCommands, ['Results', 'Browser', 'Files', 'Search']); +const narrowedSections = makeSections(narrowedCommands, ['Results', 'Browser', 'Files']); + +const scenarios = [ + { name: 'root results initial', sections: rootSections, selectedIndex: 0 }, + { name: 'selection moves to middle', sections: rootSections, selectedIndex: Math.floor(rowCount / 2) }, + { name: 'selection moves to end', sections: rootSections, selectedIndex: rowCount - 1 }, + { name: 'query update same-size reshuffle', sections: querySections, selectedIndex: 0 }, + { name: 'query update narrowed-large', sections: narrowedSections, selectedIndex: Math.min(20, narrowedCommands.length - 1) }, +]; + +const noop = () => {}; + +function renderScenario(scenario: typeof scenarios[number]) { + const displayCommands = flatten(scenario.sections); + const listRef = { current: null }; + const itemRefs = { current: [] }; + globalThis.__launcherCommandListMetrics = { rowRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( + } + itemRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>} + isLoading={false} + isHidden={false} + displayCommands={displayCommands as any} + sections={scenario.sections as any} + calcResult={null} + calcOffset={0} + selectedIndex={scenario.selectedIndex} + commandAliases={{}} + commandHotkeys={{ + 'measure-command-2': 'Command+Shift+P', + 'measure-command-7': 'Control+Option+L', + 'quicklink-measure-16': 'Hyper+K', + }} + onCalculatorCopy={noop} + onCommandClick={noop} + onCommandContextMenu={noop} + t={t} + /> + ); + const durationMs = performance.now() - started; + return { + name: scenario.name, + durationMs, + rowRenderCount: globalThis.__launcherCommandListMetrics?.rowRenderCount || 0, + commandCount: displayCommands.length, + htmlLength: html.length, + }; +} + +for (let i = 0; i < warmups; i += 1) { + for (const scenario of scenarios) { + renderScenario(scenario); + } +} + +const results = scenarios.map((scenario) => { + const samples = Array.from({ length: iterations }, () => renderScenario(scenario)); + return { + name: scenario.name, + commandCount: samples[0]?.commandCount || 0, + rowRenderCount: samples[0]?.rowRenderCount || 0, + medianDurationMs: median(samples.map((sample) => sample.durationMs)), + minDurationMs: Math.min(...samples.map((sample) => sample.durationMs)), + maxDurationMs: Math.max(...samples.map((sample) => sample.durationMs)), + htmlLength: samples[0]?.htmlLength || 0, + }; +}); + +const totalRows = results.reduce((sum, result) => sum + result.rowRenderCount, 0); +const totalMedianMs = results.reduce((sum, result) => sum + result.medianDurationMs, 0); +const summary = { rowCount, iterations, results, totalRows, totalMedianMs }; + +if (!jsonOnly) { + console.log(\`LauncherCommandList render measurement (rows=\${rowCount}, iterations=\${iterations})\`); + for (const result of results) { + console.log([ + \`- \${result.name}\`, + \`commands=\${result.commandCount}\`, + \`rowRenders=\${result.rowRenderCount}\`, + \`median=\${result.medianDurationMs.toFixed(2)}ms\`, + \`min=\${result.minDurationMs.toFixed(2)}ms\`, + \`max=\${result.maxDurationMs.toFixed(2)}ms\`, + ].join(' | ')); + } + console.log(\`Total row renders per scenario sequence: \${totalRows}\`); + console.log(\`Total median render time per scenario sequence: \${totalMedianMs.toFixed(2)}ms\`); +} +console.log(JSON.stringify(summary, null, 2)); +`); + +const instrumentLauncherRowPlugin = { + name: 'instrument-launcher-command-row', + setup(build) { + build.onLoad({ filter: /LauncherCommandRow\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + if (path.resolve(args.path) === rowPath) { + const marker = '}) => {\n'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument LauncherCommandRow render counter.'); + } + source = source.replace(marker, `${marker} const __launcherMetrics = (globalThis.__launcherCommandListMetrics ||= { rowRenderCount: 0 });\n __launcherMetrics.rowRenderCount += 1;\n`); + } + return { contents: source, loader: 'tsx' }; + }); + }, +}; + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [instrumentLauncherRowPlugin], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/measure-raycast-grid-render-churn.mjs b/scripts/measure-raycast-grid-render-churn.mjs new file mode 100644 index 00000000..fc801b90 --- /dev/null +++ b/scripts/measure-raycast-grid-render-churn.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const gridItemsPath = path.join(repoRoot, 'src/renderer/src/raycast-api/grid-runtime-items.tsx'); + +const visibleCells = readNumberArg('--visible-cells', 112); +const itemCount = readNumberArg('--items', 4096); +const selectionSteps = readNumberArg('--selection-steps', 1200); +const iterations = readNumberArg('--iterations', 15); +const warmups = readNumberArg('--warmups', 3); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.raycast-grid-render-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { performance } from 'node:perf_hooks'; +import { createGridItemsRuntime } from ${JSON.stringify(gridItemsPath)}; + +type Metrics = { + cellRenderCount: number; +}; + +const GRID_DEFAULT_COLUMNS = 5; + +declare global { + // eslint-disable-next-line no-var + var __raycastGridCellMetrics: Metrics | undefined; +} + +const visibleCells = ${visibleCells}; +const itemCount = ${itemCount}; +const selectionSteps = ${selectionSteps}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; +const noop = () => {}; + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function percentile(values: number[], pct: number): number { + if (values.length === 0) return 0; + const sorted = values.slice().sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * pct) - 1)); + return sorted[index] || 0; +} + +function resolveGridIconSource(source: string): string { + const value = String(source || '').trim(); + if (!value) return ''; + if (value.startsWith('http') || value.startsWith('data:') || value.startsWith('sc-asset:') || value.startsWith('/')) { + return value; + } + if (/\\.(svg|png|jpe?g|gif|webp|ico|tiff?)$/i.test(value)) { + return \`sc-asset://ext-asset/benchmark/\${value}\`; + } + return value; +} + +const { GridItemRenderer } = createGridItemsRuntime(resolveGridIconSource); + +class MiniNode { + nodeType: number; + nodeName: string; + tagName: string; + ownerDocument: any; + childNodes: any[]; + parentNode: any; + style: Record; + attributes: Record; + namespaceURI: string; + _text: string; + + constructor(nodeType: number, nodeName: string, ownerDocument?: any) { + this.nodeType = nodeType; + this.nodeName = nodeName; + this.tagName = nodeName; + this.ownerDocument = ownerDocument || this; + this.childNodes = []; + this.parentNode = null; + this.style = {}; + this.attributes = {}; + this.namespaceURI = 'http://www.w3.org/1999/xhtml'; + this._text = ''; + } + + appendChild(child: any) { + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + insertBefore(child: any, before: any) { + const index = this.childNodes.indexOf(before); + if (index < 0) return this.appendChild(child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + removeChild(child: any) { + const index = this.childNodes.indexOf(child); + if (index >= 0) this.childNodes.splice(index, 1); + child.parentNode = null; + return child; + } + + setAttribute(name: string, value: string) { + this.attributes[name] = String(value); + } + + removeAttribute(name: string) { + delete this.attributes[name]; + } + + addEventListener() {} + removeEventListener() {} + focus() {} + + get firstChild() { + return this.childNodes[0] || null; + } + + get lastChild() { + return this.childNodes[this.childNodes.length - 1] || null; + } + + get textContent() { + return this._text; + } + + set textContent(value: string) { + this._text = String(value); + this.childNodes = []; + } +} + +class MiniText extends MiniNode { + nodeValue: string; + + constructor(text: string, ownerDocument: any) { + super(3, '#text', ownerDocument); + this.nodeValue = String(text); + } +} + +class MiniDocument extends MiniNode { + documentElement: MiniNode; + body: MiniNode; + defaultView: any; + + constructor() { + super(9, '#document'); + this.ownerDocument = this; + this.documentElement = new MiniNode(1, 'HTML', this); + this.body = new MiniNode(1, 'BODY', this); + this.defaultView = globalThis; + } + + createElement(tag: string) { + return new MiniNode(1, tag.toUpperCase(), this); + } + + createElementNS(namespaceURI: string, tag: string) { + const node = new MiniNode(1, tag.toUpperCase(), this); + node.namespaceURI = namespaceURI; + return node; + } + + createTextNode(text: string) { + return new MiniText(text, this); + } + + addEventListener() {} + removeEventListener() {} +} + +function installMiniDom() { + const document = new MiniDocument(); + (globalThis as any).document = document; + (globalThis as any).window = globalThis; + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'supercmd-grid-benchmark' }, + configurable: true, + }); + (globalThis as any).HTMLElement = MiniNode; + (globalThis as any).HTMLIFrameElement = class {}; +} + +function makeContent(index: number): any { + switch (index % 10) { + case 0: + return \`cover-\${index}.png\`; + case 1: + return { source: \`Icon.Circle\`, color: 'blue' }; + case 2: + return { source: \`https://img.example.test/\${index}.webp\`, fallback: 'Image', mask: 'roundedRectangle' }; + case 3: + return { value: \`Icon.Star\`, color: 'yellow' }; + case 4: + return { value: { fileIcon: \`/Applications/App \${index}.app\` } }; + case 5: + return { source: { light: \`light-\${index}.png\`, dark: \`dark-\${index}.png\` }, tintColor: 'green' }; + case 6: + return { color: index % 2 === 0 ? '#32d74b' : 'magenta' }; + case 7: + return { source: \`data:image/gif;base64,R0lGODlhAQABAAAAACw=\` }; + case 8: + return { fileIcon: \`/Users/test/Documents/file-\${index}.md\` }; + default: + return null; + } +} + +function makeAccessory(index: number): any { + switch (index % 6) { + case 0: + return { icon: 'Icon.CheckCircle', tooltip: \`Ready \${index}\` }; + case 1: + return { icon: { value: 'Icon.Tag', color: 'orange' }, tooltip: \`Tagged \${index}\` }; + case 2: + return { icon: { source: \`badge-\${index}.svg\`, tintColor: 'purple' }, tooltip: \`Badge \${index}\` }; + case 3: + return { icon: { source: { light: 'light-badge.png', dark: 'dark-badge.png' } } }; + default: + return undefined; + } +} + +const cells = Array.from({ length: visibleCells }, (_, index) => ({ + title: \`Grid Item \${String(index).padStart(4, '0')}\`, + subtitle: index % 3 === 0 ? \`Subtitle \${index}\` : undefined, + content: makeContent(index), + accessory: makeAccessory(index), + isSelected: false, + dataIdx: index, + itemHeight: 128 + (index % 3) * 24, + fit: index % 4 === 0 ? 'fill' : 'contain', + inset: index % 5 === 0 ? 'zero' : index % 5 === 1 ? 'md' : index % 5 === 2 ? 'lg' : 'sm', +})); + +function renderVisibleWindow(selectedIdx: number) { + globalThis.__raycastGridCellMetrics = { cellRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( +
+ {cells.map((cell) => ( + + ))} +
, + ); + + return { + durationMs: performance.now() - started, + cellRenderCount: globalThis.__raycastGridCellMetrics?.cellRenderCount || 0, + htmlLength: html.length, + }; +} + +function GridSelectionWindow({ selectedIdx }: { selectedIdx: number }) { + return ( +
+ {cells.map((cell) => ( + {}} + onActivate={() => {}} + onContextAction={() => {}} + /> + ))} +
+ ); +} + +async function measureClientSelectionChurn() { + installMiniDom(); + const [{ createRoot }, { flushSync }] = await Promise.all([ + import('react-dom/client'), + import('react-dom'), + ]); + const container = document.createElement('div'); + const root = createRoot(container); + + flushSync(() => { + root.render(); + }); + + globalThis.__raycastGridCellMetrics = { cellRenderCount: 0 }; + const started = performance.now(); + for (let step = 1; step <= selectionSteps; step += 1) { + flushSync(() => { + root.render(); + }); + } + + const durationMs = performance.now() - started; + root.unmount(); + + return { + durationMs, + cellRenderCount: globalThis.__raycastGridCellMetrics?.cellRenderCount || 0, + }; +} + +function makeGridGroups() { + const items = Array.from({ length: itemCount }, (_, index) => ({ + item: { + id: \`grid-\${index}\`, + order: index, + props: { + id: \`grid-\${index}\`, + title: \`Grid Item \${index}\`, + subtitle: index % 4 === 0 ? \`Subtitle \${index}\` : undefined, + content: makeContent(index), + accessory: makeAccessory(index), + }, + section: index < itemCount / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + }, + globalIdx: index, + })); + + return [ + { key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: items.slice(0, Math.floor(itemCount / 2)) }, + { + key: 'section-b', + title: 'Section B', + section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + items: items.slice(Math.floor(itemCount / 2)), + }, + ]; +} + +function buildMeasurementGridLayout(groups: ReturnType) { + const rows: Array<{ startIdx: number; endIdx: number; top: number; bottom: number; columns: number }> = []; + let top = 0; + for (const group of groups) { + const columns = Number(group.section?.columns) > 0 ? Number(group.section.columns) : GRID_DEFAULT_COLUMNS; + for (let offset = 0; offset < group.items.length; offset += columns) { + const rowItems = group.items.slice(offset, offset + columns); + const rowHeight = Math.max(...rowItems.map(({ item }) => 128 + (item.order % 3) * 24)); + const startIdx = rowItems[0]?.globalIdx ?? 0; + const endIdx = rowItems[rowItems.length - 1]?.globalIdx ?? startIdx; + rows.push({ startIdx, endIdx, top, bottom: top + rowHeight, columns }); + top += rowHeight + 8; + } + } + return { rows }; +} + +function findLayoutRow(layout: ReturnType, itemIndex: number) { + return layout.rows.find((row) => itemIndex >= row.startIdx && itemIndex <= row.endIdx) || layout.rows[0]; +} + +function getColumnsForItemIndex(layout: ReturnType, itemIndex: number, fallback: number): number { + return findLayoutRow(layout, itemIndex)?.columns || fallback; +} + +function getScrollTopForItemIndex( + layout: ReturnType, + itemIndex: number, + options: { currentScrollTop: number; viewportHeight: number }, +) { + const row = findLayoutRow(layout, itemIndex); + if (!row) return options.currentScrollTop; + if (row.top < options.currentScrollTop) return row.top; + if (row.bottom > options.currentScrollTop + options.viewportHeight) return row.bottom - options.viewportHeight; + return options.currentScrollTop; +} + +const layout = buildMeasurementGridLayout(makeGridGroups()); + +function measureSelectionScroll() { + let selectedIdx = 0; + let scrollTop = 0; + let scrollUpdates = 0; + let scrollDistance = 0; + let columnsChecksum = 0; + const viewportHeight = 640; + const started = performance.now(); + + for (let step = 0; step < selectionSteps; step += 1) { + const columns = getColumnsForItemIndex(layout, selectedIdx, GRID_DEFAULT_COLUMNS); + columnsChecksum += columns; + const direction = step % 29 === 0 ? -1 : 1; + selectedIdx = Math.max(0, Math.min(itemCount - 1, selectedIdx + direction * columns)); + const nextScrollTop = getScrollTopForItemIndex(layout, selectedIdx, { + currentScrollTop: scrollTop, + viewportHeight, + }); + if (Math.abs(nextScrollTop - scrollTop) >= 1) { + scrollUpdates += 1; + scrollDistance += Math.abs(nextScrollTop - scrollTop); + scrollTop = nextScrollTop; + } + } + + return { + durationMs: performance.now() - started, + selectionSteps, + scrollUpdates, + finalScrollTop: scrollTop, + scrollDistance, + columnsChecksum, + }; +} + +for (let i = 0; i < warmups; i += 1) { + renderVisibleWindow(i % visibleCells); + measureSelectionScroll(); +} + +const cellSamples = Array.from({ length: iterations }, (_, index) => renderVisibleWindow(index % visibleCells)); +const scrollSamples = Array.from({ length: iterations }, () => measureSelectionScroll()); +const selectionChurnSamples = []; +for (let index = 0; index < iterations; index += 1) { + selectionChurnSamples.push(await measureClientSelectionChurn()); +} +const cellDurations = cellSamples.map((sample) => sample.durationMs); +const scrollDurations = scrollSamples.map((sample) => sample.durationMs); +const selectionChurnDurations = selectionChurnSamples.map((sample) => sample.durationMs); + +const summary = { + visibleCells, + itemCount, + selectionSteps, + iterations, + cellRender: { + cellRenderCount: cellSamples[0]?.cellRenderCount || 0, + medianDurationMs: median(cellDurations), + p95DurationMs: percentile(cellDurations, 0.95), + minDurationMs: Math.min(...cellDurations), + maxDurationMs: Math.max(...cellDurations), + htmlLength: cellSamples[0]?.htmlLength || 0, + }, + selectionScroll: { + scrollUpdates: scrollSamples[0]?.scrollUpdates || 0, + finalScrollTop: scrollSamples[0]?.finalScrollTop || 0, + scrollDistance: scrollSamples[0]?.scrollDistance || 0, + columnsChecksum: scrollSamples[0]?.columnsChecksum || 0, + medianDurationMs: median(scrollDurations), + p95DurationMs: percentile(scrollDurations, 0.95), + minDurationMs: Math.min(...scrollDurations), + maxDurationMs: Math.max(...scrollDurations), + }, + selectionRenderChurn: { + cellRenderCount: selectionChurnSamples[0]?.cellRenderCount || 0, + medianDurationMs: median(selectionChurnDurations), + p95DurationMs: percentile(selectionChurnDurations, 0.95), + minDurationMs: Math.min(...selectionChurnDurations), + maxDurationMs: Math.max(...selectionChurnDurations), + }, +}; + +if (!jsonOnly) { + console.log(\`Raycast Grid render churn measurement (visibleCells=\${visibleCells}, items=\${itemCount}, iterations=\${iterations})\`); + console.log([ + '- visible cell render', + \`cellRenders=\${summary.cellRender.cellRenderCount}\`, + \`median=\${summary.cellRender.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.cellRender.p95DurationMs.toFixed(3)}ms\`, + \`min=\${summary.cellRender.minDurationMs.toFixed(3)}ms\`, + \`max=\${summary.cellRender.maxDurationMs.toFixed(3)}ms\`, + ].join(' | ')); + console.log([ + '- rapid selection scroll', + \`steps=\${selectionSteps}\`, + \`scrollUpdates=\${summary.selectionScroll.scrollUpdates}\`, + \`median=\${summary.selectionScroll.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.selectionScroll.p95DurationMs.toFixed(3)}ms\`, + ].join(' | ')); + console.log([ + '- selection render churn', + \`steps=\${selectionSteps}\`, + \`cellRenders=\${summary.selectionRenderChurn.cellRenderCount}\`, + \`median=\${summary.selectionRenderChurn.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.selectionRenderChurn.p95DurationMs.toFixed(3)}ms\`, + ].join(' | ')); +} + +console.log(JSON.stringify(summary, null, 2)); +`); + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [{ + name: 'instrument-grid-item-renderer', + setup(build) { + build.onLoad({ filter: /grid-runtime-items\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + const marker = ' const swatchColor = getGridColor(content);'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument GridItemRenderer render count.'); + } + source = source.replace( + marker, + ' const __gridMetrics = (globalThis.__raycastGridCellMetrics ||= { cellRenderCount: 0 });\n __gridMetrics.cellRenderCount += 1;\n' + marker, + ); + return { contents: source, loader: 'tsx' }; + }); + }, + }], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/run-node-tests.mjs b/scripts/run-node-tests.mjs new file mode 100644 index 00000000..89c61062 --- /dev/null +++ b/scripts/run-node-tests.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const TEST_DIR = path.join(REPO_ROOT, 'scripts'); +const EXCLUDED_PERF_TESTS = new Set([ + 'test-browser-search-performance.mjs', + 'test-file-search-perf-harness.mjs', + 'test-root-search-perf.mjs', +]); + +const args = new Set(process.argv.slice(2)); +const excludePerf = args.has('--exclude-perf'); +const entries = await fs.readdir(TEST_DIR); +const testFiles = entries + .filter((entry) => /^test-.*\.mjs$/.test(entry)) + .filter((entry) => !excludePerf || !EXCLUDED_PERF_TESTS.has(entry)) + .sort() + .map((entry) => path.join('scripts', entry)); + +if (testFiles.length === 0) { + throw new Error('No Node test files matched the requested filters.'); +} + +if (excludePerf) { + console.log(`Running ${testFiles.length} Node test files; excluded ${EXCLUDED_PERF_TESTS.size} perf harness files.`); +} + +const child = spawn(process.execPath, ['--test', ...testFiles], { + cwd: REPO_ROOT, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/scripts/test-abortable-promise-hooks.mjs b/scripts/test-abortable-promise-hooks.mjs new file mode 100644 index 00000000..a49b4433 --- /dev/null +++ b/scripts/test-abortable-promise-hooks.mjs @@ -0,0 +1,548 @@ +#!/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'); + +let activeHost = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + 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: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request === 'react') return fakeReact; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + AbortController, + Array, + console, + Date, + Error, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + URL, + exports: module.exports, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function currentController(abortable) { + const controller = abortable.current; + assert.ok(controller instanceof AbortController, 'abortable.current should be an AbortController for each run'); + return controller; +} + +const { usePromise } = loadTsModule('src/renderer/src/raycast-api/hooks/use-promise.ts'); +const { useCachedPromise } = loadTsModule('src/renderer/src/raycast-api/hooks/use-cached-promise.ts'); + +test('usePromise aborts the previous abortable run before revalidate starts another run', async () => { + const abortable = { current: null }; + const controllers = []; + const pending = []; + const fn = () => { + controllers.push(currentController(abortable)); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + assert.equal(controllers.length, 1); + + host.output.revalidate(); + await flushAsync(); + + assert.equal(controllers.length, 2); + assert.equal(controllers[0].signal.aborted, true); + assert.notEqual(controllers[1], controllers[0]); + assert.equal(controllers[1].signal.aborted, false); + + host.unmount(); +}); + +test('useCachedPromise aborts the previous abortable run before revalidate starts another run', async () => { + const abortable = { current: null }; + const controllers = []; + const pending = []; + const fn = () => { + controllers.push(currentController(abortable)); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + assert.equal(controllers.length, 1); + + host.output.revalidate(); + await flushAsync(); + + assert.equal(controllers.length, 2); + assert.equal(controllers[0].signal.aborted, true); + assert.notEqual(controllers[1], controllers[0]); + assert.equal(controllers[1].signal.aborted, false); + + host.unmount(); +}); + +test('usePromise aborts active abortable work on unmount and clears the abortable ref', async () => { + const abortable = { current: null }; + const controllers = []; + const fn = () => { + controllers.push(currentController(abortable)); + return deferred().promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + host.unmount(); + + assert.equal(controllers.length, 1); + assert.equal(controllers[0].signal.aborted, true); + assert.equal(abortable.current, null); +}); + +test('useCachedPromise aborts active abortable work on unmount and clears the abortable ref', async () => { + const abortable = { current: null }; + const controllers = []; + const fn = () => { + controllers.push(currentController(abortable)); + return deferred().promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + host.unmount(); + + assert.equal(controllers.length, 1); + assert.equal(controllers[0].signal.aborted, true); + assert.equal(abortable.current, null); +}); + +test('usePromise keeps the latest abortable result when an older aborted run resolves later', async () => { + const abortable = { current: null }; + const pending = []; + const onDataCalls = []; + const fn = () => { + currentController(abortable); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable, onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useCachedPromise keeps the latest abortable result when an older aborted run resolves later', async () => { + const abortable = { current: null }; + const pending = []; + const onDataCalls = []; + const fn = () => { + currentController(abortable); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable, onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('usePromise keeps the latest default result when an older run resolves later', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useCachedPromise keeps the latest default result when an older run resolves later', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('usePromise ignores stale default results while a newer run is still loading', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, undefined); + assert.equal(host.output.isLoading, true); + assert.deepEqual(onDataCalls, []); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useCachedPromise ignores stale default results while a newer run is still loading', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, undefined); + assert.equal(host.output.isLoading, true); + assert.deepEqual(onDataCalls, []); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('usePromise ignores stale default errors after a newer run resolves', async () => { + const pending = []; + const onErrorCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { onError: (error) => onErrorCalls.push(error.message) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + pending[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.error, undefined); + assert.deepEqual(onErrorCalls, []); + + host.unmount(); +}); + +test('useCachedPromise ignores stale default errors after a newer run resolves', async () => { + const pending = []; + const onErrorCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { onError: (error) => onErrorCalls.push(error.message) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + pending[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.error, undefined); + assert.deepEqual(onErrorCalls, []); + + host.unmount(); +}); diff --git a/scripts/test-aerospace-workspace.mjs b/scripts/test-aerospace-workspace.mjs new file mode 100644 index 00000000..39f8598c --- /dev/null +++ b/scripts/test-aerospace-workspace.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { createAerospaceWorkspaceMover } = await importTs(path.join(root, 'src/main/aerospace-workspace.ts')); + +test('AeroSpace workspace mover', async (t) => { + 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-ai-chat-stream-batching.mjs b/scripts/test-ai-chat-stream-batching.mjs new file mode 100644 index 00000000..066a2c4b --- /dev/null +++ b/scripts/test-ai-chat-stream-batching.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const CHUNK_COUNT = 240; + +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: {} }; + vm.runInNewContext(transpiled.outputText, { + module, + exports: module.exports, + require, + console, + setTimeout, + clearTimeout, + }, { filename: resolvedPath }); + return module.exports; +} + +const { + AI_CHAT_STREAM_FLUSH_MS, + createAiChatStreamBuffer, +} = loadTsModule('src/renderer/src/utils/ai-chat-stream-buffer.ts'); + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index}\n`); +} + +function simulateUnbatchedStreaming(chunks) { + let content = ''; + let visibleUpdates = 0; + let markdownReparseChars = 0; + let layoutMeasurements = 0; + + const startedAt = performance.now(); + for (const chunk of chunks) { + content += chunk; + visibleUpdates += 1; + markdownReparseChars += content.length; + layoutMeasurements += 1; + } + + return { + content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements, + markdownReparseChars, + visibleUpdates, + }; +} + +function createManualScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + get size() { + return callbacks.size; + }, + schedule(callback) { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }, + cancel(handle) { + callbacks.delete(handle); + }, + runNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [handle, callback] = next.value; + callbacks.delete(handle); + callback(); + return true; + }, + }; +} + +function createRenderCounters() { + return { + content: '', + layoutMeasurements: 0, + markdownReparseChars: 0, + visibleUpdates: 0, + onFlush(content) { + this.content = content; + this.visibleUpdates += 1; + this.layoutMeasurements += 1; + this.markdownReparseChars += content.length; + }, + }; +} + +function simulateBatchedBurstStreaming(chunks) { + const scheduler = createManualScheduler(); + const counters = createRenderCounters(); + const startedAt = performance.now(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => counters.onFlush(content), + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + for (const chunk of chunks) { + buffer.append(chunk); + } + assert.equal(scheduler.size, 1); + buffer.flushNow(); + assert.equal(scheduler.size, 0); + + return { + content: counters.content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements: counters.layoutMeasurements, + markdownReparseChars: counters.markdownReparseChars, + visibleUpdates: counters.visibleUpdates, + }; +} + +function createConversationHarness() { + const scheduler = createManualScheduler(); + let messages = [ + { id: 'user-1', role: 'user', content: 'Question', createdAt: 1 }, + { id: 'assistant-1', role: 'assistant', content: '', createdAt: 2 }, + ]; + const persisted = []; + const counters = createRenderCounters(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => { + counters.onFlush(content); + messages = messages.map((message) => ( + message.id === 'assistant-1' + ? { ...message, content } + : message + )); + }, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + return { + append(chunk) { + buffer.append(chunk); + }, + complete() { + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + fail(error) { + const currentContent = buffer.getContent(); + buffer.append(`${currentContent ? '\n\n' : ''}Error: ${error}`); + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + get counters() { + return counters; + }, + get messages() { + return messages; + }, + get persisted() { + return persisted; + }, + get schedulerSize() { + return scheduler.size; + }, + flushScheduled() { + return scheduler.runNext(); + }, + }; +} + +function test(name, fn) { + fn(); + console.log(`PASS ${name}`); +} + +const chunks = makeChunks(); +const baseline = simulateUnbatchedStreaming(chunks); +const batched = simulateBatchedBurstStreaming(chunks); + +assert.equal(baseline.content, chunks.join('')); +assert.equal(baseline.visibleUpdates, CHUNK_COUNT); +assert.equal(baseline.layoutMeasurements, CHUNK_COUNT); +assert.equal(batched.content, baseline.content); +assert.ok(batched.visibleUpdates < baseline.visibleUpdates); +assert.ok(batched.markdownReparseChars < baseline.markdownReparseChars); + +test('scheduled flush exposes partial streaming content', () => { + const harness = createConversationHarness(); + harness.append('Hello'); + assert.equal(harness.schedulerSize, 1); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello'); + harness.append(' world'); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello world'); + assert.equal(harness.counters.visibleUpdates, 2); +}); + +test('completion forces final flush before persistence', () => { + const harness = createConversationHarness(); + chunks.forEach((chunk) => harness.append(chunk)); + assert.equal(harness.messages[1].content, ''); + harness.complete(); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, chunks.join('')); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +test('error forces authoritative content plus error before persistence', () => { + const harness = createConversationHarness(); + harness.append('partial'); + harness.append(' answer'); + harness.fail('network failed'); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, 'partial answer\n\nError: network failed'); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +console.log(JSON.stringify({ + mode: 'unbatched-baseline', + chunks: CHUNK_COUNT, + visibleUpdates: baseline.visibleUpdates, + layoutMeasurements: baseline.layoutMeasurements, + markdownReparseChars: baseline.markdownReparseChars, + elapsedMs: Number(baseline.elapsedMs.toFixed(3)), +}, null, 2)); + +console.log(JSON.stringify({ + mode: 'batched-burst', + chunks: CHUNK_COUNT, + flushWindowMs: AI_CHAT_STREAM_FLUSH_MS, + visibleUpdates: batched.visibleUpdates, + layoutMeasurements: batched.layoutMeasurements, + markdownReparseChars: batched.markdownReparseChars, + elapsedMs: Number(batched.elapsedMs.toFixed(3)), +}, null, 2)); diff --git a/scripts/test-ai-model-status-polling.mjs b/scripts/test-ai-model-status-polling.mjs new file mode 100644 index 00000000..e20149e2 --- /dev/null +++ b/scripts/test-ai-model-status-polling.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const FIVE_SECOND_TICKS = 5; + +const { + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, +} = await importTs(path.resolve('src/renderer/src/settings/aiModelStatusPolling.ts')); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeWhisperCppStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'base', + path: '/models/ggml-base.bin', + bytesDownloaded: 1024, + totalBytes: 1024, + ...overrides, + }; +} + +function makeParakeetStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function makeQwen3Status(overrides = {}) { + return { + state: 'downloaded', + modelName: 'qwen3-asr', + path: '/models/qwen3', + progress: 1, + ...overrides, + }; +} + +function makeSnapshot(overrides = {}) { + return { + whisperCpp: makeWhisperCppStatus(overrides.whisperCpp), + parakeet: makeParakeetStatus(overrides.parakeet), + qwen3: makeQwen3Status(overrides.qwen3), + }; +} + +function createStaticFetchers(snapshot) { + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + + return { + calls, + fetchers: { + async whisperCpp() { + calls.whisperCpp += 1; + return clone(snapshot.whisperCpp); + }, + async parakeet() { + calls.parakeet += 1; + return clone(snapshot.parakeet); + }, + async qwen3() { + calls.qwen3 += 1; + return clone(snapshot.qwen3); + }, + }, + }; +} + +function createFixedPollingHarness(initialSnapshot) { + let current = initialSnapshot; + let stateUpdates = 0; + let profilerCommits = 0; + + function applyPatch(patch) { + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + current = next; + stateUpdates += 1; + profilerCommits += 1; + return true; + } + + return { + async poll(fetchers) { + const patch = await fetchAiModelStatusPollPatch(fetchers); + applyPatch(patch); + }, + applyPatch, + get current() { + return current; + }, + get metrics() { + return { stateUpdates, profilerCommits }; + }, + }; +} + +async function simulateLegacyIndependentPolling(fetchers, ticks) { + let stateUpdates = 0; + let profilerCommits = 0; + + for (let tick = 0; tick < ticks; tick += 1) { + await fetchers.whisperCpp(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.parakeet(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.qwen3(); + stateUpdates += 1; + profilerCommits += 1; + } + + return { stateUpdates, profilerCommits }; +} + +test('AI model status polling baseline performs three unchanged state writes per second', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + + const metrics = await simulateLegacyIndependentPolling(fetchers, FIVE_SECOND_TICKS); + + console.log( + `[ai-status baseline] ticks=${FIVE_SECOND_TICKS} stateUpdates=${metrics.stateUpdates} profilerCommits=${metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(metrics, { stateUpdates: 15, profilerCommits: 15 }); +}); + +test('coalesced AI model status polling skips commits when statuses are unchanged', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + const harness = createFixedPollingHarness(clone(snapshot)); + + for (let tick = 0; tick < FIVE_SECOND_TICKS; tick += 1) { + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced unchanged] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 0, profilerCommits: 0 }); +}); + +test('coalesced AI model status polling commits once per tick when all statuses change', async () => { + let tickValue = 0; + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + const harness = createFixedPollingHarness(makeSnapshot({ + whisperCpp: { bytesDownloaded: 0, totalBytes: 100 }, + parakeet: { progress: 0 }, + qwen3: { progress: 0 }, + })); + const fetchers = { + async whisperCpp() { + calls.whisperCpp += 1; + return makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: tickValue, + totalBytes: 100, + }); + }, + async parakeet() { + calls.parakeet += 1; + return makeParakeetStatus({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + async qwen3() { + calls.qwen3 += 1; + return makeQwen3Status({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + }; + + for (let tick = 1; tick <= FIVE_SECOND_TICKS; tick += 1) { + tickValue = tick; + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced changing] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 5, profilerCommits: 5 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, FIVE_SECOND_TICKS); + assert.equal(harness.current.parakeet.progress, 1); + assert.equal(harness.current.qwen3.progress, 1); +}); + +test('download progress status patches still commit when progress fields change', () => { + const harness = createFixedPollingHarness(createEmptyAiModelStatusSnapshot()); + + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), false); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 25, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + parakeet: makeParakeetStatus({ state: 'downloading', progress: 0.25 }), + }), true); + assert.equal(harness.applyPatch({ + qwen3: makeQwen3Status({ state: 'downloading', progress: 0.5 }), + }), true); + + assert.deepEqual(harness.metrics, { stateUpdates: 4, profilerCommits: 4 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, 25); + assert.equal(harness.current.parakeet.progress, 0.25); + assert.equal(harness.current.qwen3.progress, 0.5); +}); + +test('coalesced poll starts all status requests before awaiting any one result', async () => { + const started = []; + const resolvers = []; + const fetchers = { + whisperCpp: () => new Promise((resolve) => { + started.push('whisperCpp'); + resolvers.push(() => resolve(makeWhisperCppStatus())); + }), + parakeet: () => new Promise((resolve) => { + started.push('parakeet'); + resolvers.push(() => resolve(makeParakeetStatus())); + }), + qwen3: () => new Promise((resolve) => { + started.push('qwen3'); + resolvers.push(() => resolve(makeQwen3Status())); + }), + }; + + const patchPromise = fetchAiModelStatusPollPatch(fetchers); + + assert.deepEqual(started, ['whisperCpp', 'parakeet', 'qwen3']); + for (const resolve of resolvers) resolve(); + assert.deepEqual(await patchPromise, makeSnapshot()); +}); diff --git a/scripts/test-ai-provider-gemini-prompt-streaming.mjs b/scripts/test-ai-provider-gemini-prompt-streaming.mjs new file mode 100644 index 00000000..90bade89 --- /dev/null +++ b/scripts/test-ai-provider-gemini-prompt-streaming.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { setImmediate as waitImmediate } from 'node:timers/promises'; +import { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +export function request(options, callback) { + const harness = globalThis.__geminiHttpsHarness; + if (!harness) throw new Error('Gemini HTTPS harness is not installed'); + return harness.request(options, callback); +} +export default { request }; +`; + +const { streamAI } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { https: HTTPS_STUB }, +}); + +const GEMINI_CONFIG = { + enabled: true, + provider: 'gemini', + geminiApiKey: 'test-gemini-key', +}; + +function createGeminiHttpsHarness() { + const requests = []; + + return { + requests, + request(options, callback) { + const record = { + options, + body: '', + destroyed: false, + response: null, + responseBytesWritten: 0, + }; + + const req = new EventEmitter(); + req.write = (chunk) => { + record.body += chunk.toString(); + return true; + }; + req.end = () => { + const response = new PassThrough(); + response.statusCode = 200; + record.response = response; + callback(response); + }; + req.destroy = () => { + record.destroyed = true; + record.response?.destroy(); + }; + + requests.push(record); + return req; + }, + }; +} + +function installGeminiHarness() { + const harness = createGeminiHttpsHarness(); + globalThis.__geminiHttpsHarness = harness; + return harness; +} + +async function waitForRequestResponse(harness) { + for (let attempt = 0; attempt < 50; attempt += 1) { + const request = harness.requests[0]; + if (request?.response) return request; + await waitImmediate(); + } + assert.fail('timed out waiting for Gemini HTTPS request'); +} + +function geminiSseFrame(payload) { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function geminiTextFrame(parts) { + return geminiSseFrame({ + candidates: [{ content: { parts } }], + }); +} + +function writeResponseChunk(request, chunk) { + request.responseBytesWritten += Buffer.byteLength(chunk); + request.response.write(chunk); +} + +function endResponse(request, chunk = '') { + if (chunk) writeResponseChunk(request, chunk); + request.response.end(); +} + +async function collectGeminiPrompt(frames, options = {}) { + const harness = installGeminiHarness(); + const chunks = []; + const stream = streamAI(GEMINI_CONFIG, { + prompt: options.prompt || 'Write a concise answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.3, + systemPrompt: options.systemPrompt, + }); + + const collecting = (async () => { + for await (const chunk of stream) chunks.push(chunk); + return chunks; + })(); + + const request = await waitForRequestResponse(harness); + for (const frame of frames) writeResponseChunk(request, frame); + request.response.end(); + + return { + chunks: await collecting, + request, + }; +} + +function legacyGeminiResponseText(parsed) { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; + return parts.map((part) => (typeof part?.text === 'string' ? part.text : '')).join(''); +} + +test('Gemini prompt streaming uses SSE and yields before the response ends', async (t) => { + const harness = installGeminiHarness(); + const stream = streamAI(GEMINI_CONFIG, { + prompt: 'Stream this answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.4, + systemPrompt: 'Keep it short', + }); + const iterator = stream[Symbol.asyncIterator](); + + let firstSettled = false; + const firstRead = iterator.next().then((result) => { + firstSettled = true; + return result; + }); + + const request = await waitForRequestResponse(harness); + await waitImmediate(); + assert.equal(firstSettled, false, 'first read should wait for provider bytes'); + assert.match(request.options.path, /:streamGenerateContent\?alt=sse&key=test-gemini-key$/); + assert.doesNotMatch(request.options.path, /:generateContent\?/); + + const body = JSON.parse(request.body); + assert.deepEqual(body.contents, [{ role: 'user', parts: [{ text: 'Stream this answer' }] }]); + assert.deepEqual(body.systemInstruction, { parts: [{ text: 'Keep it short' }] }); + + const firstFrame = geminiTextFrame([{ text: 'Hello ' }]); + const secondFrame = geminiTextFrame([{ text: 'world' }]); + const finishFrame = geminiSseFrame({ candidates: [{ finishReason: 'STOP' }] }); + const totalSseBytes = Buffer.byteLength(firstFrame + secondFrame + finishFrame); + + writeResponseChunk(request, firstFrame); + const first = await firstRead; + assert.deepEqual(first, { done: false, value: 'Hello ' }); + assert.equal(request.response.readableEnded, false, 'first chunk should be visible before stream end'); + + const bytesBeforeFirstYield = request.responseBytesWritten; + const secondRead = iterator.next(); + writeResponseChunk(request, secondFrame); + assert.deepEqual(await secondRead, { done: false, value: 'world' }); + + const doneRead = iterator.next(); + endResponse(request, finishFrame); + assert.deepEqual(await doneRead, { done: true, value: undefined }); + + const legacyGenerateContentBody = JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'Hello ' }, { text: 'world' }] } }], + }); + + assert.ok(bytesBeforeFirstYield < totalSseBytes); + t.diagnostic(`legacy generateContent buffered bytes before first yield: ${Buffer.byteLength(legacyGenerateContentBody)}`); + t.diagnostic(`streamGenerateContent bytes before first yield: ${bytesBeforeFirstYield} of ${totalSseBytes}`); + t.diagnostic('streamGenerateContent first yield occurred before response end: true'); +}); + +test('Gemini prompt SSE extraction preserves final joined text', async () => { + const legacyShape = { + candidates: [{ + content: { + parts: [ + { text: 'Alpha ' }, + { text: 'beta' }, + { inlineData: { mimeType: 'text/plain' } }, + { text: ' gamma' }, + ], + }, + }], + }; + const frames = [ + geminiTextFrame([{ text: 'Alpha ' }, { text: 'beta' }, { inlineData: { mimeType: 'text/plain' } }]), + geminiTextFrame([{ text: ' gamma' }]), + ]; + + const { chunks } = await collectGeminiPrompt(frames); + + assert.deepEqual(chunks, ['Alpha beta', ' gamma']); + assert.equal(chunks.join(''), legacyGeminiResponseText(legacyShape)); +}); + +test('Gemini prompt streaming preserves finishReason no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ finishReason: 'SAFETY' }] }), + ]), + /Gemini returned no text \(SAFETY\)\./, + ); +}); + +test('Gemini prompt streaming preserves generic no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ content: { parts: [{ text: '' }] } }] }), + ]), + /Gemini returned no text\./, + ); +}); diff --git a/scripts/test-ai-provider-whisper-upload.mjs b/scripts/test-ai-provider-whisper-upload.mjs new file mode 100644 index 00000000..19f56b8f --- /dev/null +++ b/scripts/test-ai-provider-whisper-upload.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +module.exports = { + request: (...args) => globalThis.__supercmdWhisperHttps.request(...args), +}; +`; + +const { transcribeAudio } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { + https: HTTPS_STUB, + }, +}); + +test('transcribeAudio streams Whisper multipart upload bytes without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(8 * 1024 * 1024, 0x61); + const https = createFakeHttps({ responseBody: ' hello from whisper \n' }); + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'whisper-1', + language: 'en', + mimeType: 'audio/wav', + })); + + assert.equal(result, 'hello from whisper'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + language: 'en', + mimeType: 'audio/wav', + model: 'whisper-1', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + authorization: req.options.headers.Authorization, + contentLength: req.options.headers['Content-Length'], + }, + { + hostname: 'api.openai.com', + path: '/v1/audio/transcriptions', + method: 'POST', + authorization: 'Bearer test-api-key', + contentLength: expectedBody.length, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assert.equal(Math.max(...req.writes.map((chunk) => chunk.length)), audioBuffer.length); + assert.equal(concatCalls.length, 0, 'production upload path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"', 'name="language"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('transcribeAudio preserves no-language multipart ordering and default upload metadata', async () => { + const audioBuffer = Buffer.from('tiny-audio'); + const https = createFakeHttps({ responseBody: 'ok' }); + + await transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'gpt-4o-transcribe', + }); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + model: 'gpt-4o-transcribe', + }); + const actualBody = Buffer.concat(req.writes); + const actualText = actualBody.toString('utf8'); + + assert.equal(req.options.headers['Content-Length'], Buffer.concat(expectedParts).length); + assert.equal(actualBody.equals(Buffer.concat(expectedParts)), true); + assert.equal(req.writes.length, expectedParts.length); + assert.match(actualText, /filename="audio\.webm"/); + assert.match(actualText, /Content-Type: audio\/webm/); + assert.doesNotMatch(actualText, /name="language"/); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"']); +}); + +test('transcribeAudio preserves Whisper HTTP and request error handling', async () => { + { + createFakeHttps({ statusCode: 429, responseBody: 'rate limited by test' }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /Whisper API HTTP 429: rate limited by test/, + ); + } + + { + createFakeHttps({ requestError: new Error('socket failed') }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /socket failed/, + ); + } +}); + +test('transcribeAudio preserves pre-aborted request behavior', async () => { + const controller = new AbortController(); + controller.abort(); + const https = createFakeHttps(); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + signal: controller.signal, + }), + /Transcription aborted/, + ); + + assert.equal(https.requests.length, 1); + assert.equal(https.requests[0].destroyed, true); + assert.equal(https.requests[0].ended, false); + assert.equal(https.requests[0].writes.length, 0); +}); + +async function instrumentBufferConcat(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + const result = await fn(); + return { result, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +function createFakeHttps(options = {}) { + const requests = []; + const fakeHttps = { + requests, + request(requestOptions, onResponse) { + const req = new FakeClientRequest(requestOptions, onResponse, options); + requests.push(req); + return req; + }, + }; + + globalThis.__supercmdWhisperHttps = fakeHttps; + return fakeHttps; +} + +class FakeClientRequest extends EventEmitter { + constructor(options, onResponse, responseOptions) { + super(); + this.options = options; + this.onResponse = onResponse; + this.responseOptions = responseOptions; + this.destroyed = false; + this.ended = false; + this.writes = []; + } + + write(chunk) { + this.writes.push(chunk); + return true; + } + + end() { + this.ended = true; + queueMicrotask(() => { + if (this.responseOptions.requestError) { + this.emit('error', this.responseOptions.requestError); + return; + } + if (this.destroyed) return; + + const res = new EventEmitter(); + res.statusCode = this.responseOptions.statusCode ?? 200; + this.onResponse(res); + res.emit('data', this.responseOptions.responseBody ?? 'transcript'); + res.emit('end'); + }); + } + + destroy() { + this.destroyed = true; + } +} + +function extractBoundary(contentType) { + const match = /^multipart\/form-data; boundary=(.+)$/.exec(contentType); + assert.ok(match, `expected multipart content type, got ${contentType}`); + return match[1]; +} + +function buildExpectedMultipartParts({ audioBuffer, boundary, language, mimeType, model }) { + const uploadMeta = resolveExpectedUploadMeta(mimeType); + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${uploadMeta.filename}"\r\nContent-Type: ${uploadMeta.contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model}\r\n`), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\ntext\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function resolveExpectedUploadMeta(mimeType) { + const normalized = String(mimeType || '').toLowerCase(); + if (normalized.includes('wav')) return { filename: 'audio.wav', contentType: 'audio/wav' }; + if (normalized.includes('mpeg') || normalized.includes('mp3')) return { filename: 'audio.mp3', contentType: 'audio/mpeg' }; + if (normalized.includes('mp4') || normalized.includes('m4a')) return { filename: 'audio.m4a', contentType: 'audio/mp4' }; + if (normalized.includes('ogg') || normalized.includes('oga')) return { filename: 'audio.ogg', contentType: 'audio/ogg' }; + if (normalized.includes('flac')) return { filename: 'audio.flac', contentType: 'audio/flac' }; + return { filename: 'audio.webm', contentType: 'audio/webm' }; +} + +function assertMultipartOrder(body, markers) { + const text = body.toString('utf8'); + let previous = -1; + + for (const marker of markers) { + const index = text.indexOf(marker); + assert.notEqual(index, -1, `expected multipart marker ${marker}`); + assert.ok(index > previous, `expected ${marker} after previous multipart field`); + previous = index; + } +} diff --git a/scripts/test-ai-stream-ipc-batching.mjs b/scripts/test-ai-stream-ipc-batching.mjs new file mode 100644 index 00000000..58f5608a --- /dev/null +++ b/scripts/test-ai-stream-ipc-batching.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const CHUNK_COUNT = 600; + +const { + AI_STREAM_IPC_FLUSH_MS, + createAIStreamIpcCoalescer, + forwardAIStreamChunksToIpc, +} = await importTs(path.resolve('src/main/ai-stream-ipc.ts')); + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +async function* streamChunks(chunks) { + for (const chunk of chunks) { + yield chunk; + } +} + +function createManualScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + schedule(callback) { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }, + cancel(handle) { + callbacks.delete(handle); + }, + runNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [handle, callback] = next.value; + callbacks.delete(handle); + callback(); + return true; + }, + pendingCount() { + return callbacks.size; + }, + }; +} + +function createSender() { + const events = []; + return { + events, + send(channel, payload) { + events.push({ channel, payload }); + }, + }; +} + +function aiStreamChunkEvents(events) { + return events.filter((event) => event.channel === 'ai-stream-chunk'); +} + +function joinedChunkText(events) { + return aiStreamChunkEvents(events).map((event) => event.payload.chunk).join(''); +} + +async function simulateLegacyIpcSend(chunks) { + const sender = createSender(); + for await (const chunk of streamChunks(chunks)) { + sender.send('ai-stream-chunk', { requestId: 'request-1', chunk }); + } + return sender.events; +} + +async function simulateBatchedIpcSend(chunks) { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'request-1', + sender, + flushIntervalMs: AI_STREAM_IPC_FLUSH_MS, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + await forwardAIStreamChunksToIpc(streamChunks(chunks), coalescer, controller.signal); + + assert.equal(sender.events.length, 0, 'burst should wait for the scheduled IPC flush'); + assert.equal(scheduler.pendingCount(), 1, 'burst should schedule one coalesced IPC flush'); + coalescer.flush(); + assert.equal(scheduler.pendingCount(), 0); + + return sender.events; +} + +test('main-process AI stream IPC batching coalesces provider chunk bursts', async (t) => { + const chunks = makeChunks(); + const expectedText = chunks.join(''); + + const legacyEvents = await simulateLegacyIpcSend(chunks); + const batchedEvents = await simulateBatchedIpcSend(chunks); + + const legacyChunkSends = aiStreamChunkEvents(legacyEvents).length; + const batchedChunkSends = aiStreamChunkEvents(batchedEvents).length; + + assert.equal(legacyChunkSends, CHUNK_COUNT); + assert.equal(joinedChunkText(legacyEvents), expectedText); + assert.equal(joinedChunkText(batchedEvents), expectedText); + assert.ok(batchedChunkSends < legacyChunkSends / 10); + + t.diagnostic(`baseline ai-stream-chunk IPC sends: ${legacyChunkSends} for ${CHUNK_COUNT} provider chunks`); + t.diagnostic(`batched ai-stream-chunk IPC sends: ${batchedChunkSends} for ${CHUNK_COUNT} provider chunks`); +}); + +test('main-process AI stream IPC flushes pending text before done', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'done-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + coalescer.appendChunk('final '); + coalescer.appendChunk('answer'); + coalescer.flush(); + sender.send('ai-stream-done', { requestId: 'done-request' }); + + assert.deepEqual(sender.events.map((event) => event.channel), [ + 'ai-stream-chunk', + 'ai-stream-done', + ]); + assert.equal(joinedChunkText(sender.events), 'final answer'); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes pending text before error', async () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'error-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + async function* failingStream() { + yield 'partial '; + yield 'answer'; + throw new Error('provider failed'); + } + + try { + await forwardAIStreamChunksToIpc(failingStream(), coalescer, controller.signal); + assert.fail('expected provider stream to fail'); + } catch (error) { + coalescer.flush(); + sender.send('ai-stream-error', { + requestId: 'error-request', + error: error instanceof Error ? error.message : 'AI request failed', + }); + } + + assert.deepEqual(sender.events.map((event) => event.channel), [ + 'ai-stream-chunk', + 'ai-stream-error', + ]); + assert.equal(joinedChunkText(sender.events), 'partial answer'); + assert.equal(sender.events[1].payload.error, 'provider failed'); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes pending text during cancellation cleanup', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'cancel-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + coalescer.appendChunk('visible before cancel'); + assert.equal(coalescer.hasPendingChunk(), true); + assert.equal(coalescer.hasPendingFlush(), true); + + coalescer.flush(); + controller.abort(); + + assert.equal(controller.signal.aborted, true); + assert.equal(joinedChunkText(sender.events), 'visible before cancel'); + assert.equal(aiStreamChunkEvents(sender.events).length, 1); + assert.equal(coalescer.hasPendingChunk(), false); + assert.equal(coalescer.hasPendingFlush(), false); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes the previous buffer before request replacement', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const previous = createAIStreamIpcCoalescer({ + requestId: 'same-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + previous.appendChunk('old buffered text'); + previous.flush(); + + const replacement = createAIStreamIpcCoalescer({ + requestId: 'same-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + replacement.appendChunk('new buffered text'); + replacement.flush(); + + assert.deepEqual( + aiStreamChunkEvents(sender.events).map((event) => event.payload.chunk), + ['old buffered text', 'new buffered text'], + ); + assert.equal(scheduler.pendingCount(), 0); +}); diff --git a/scripts/test-audio-wav-bulk-write.swift b/scripts/test-audio-wav-bulk-write.swift new file mode 100644 index 00000000..3dd516c2 --- /dev/null +++ b/scripts/test-audio-wav-bulk-write.swift @@ -0,0 +1,140 @@ +import Foundation + +private let sampleRate: Double = 16_000 +private let sampleCount = Int(sampleRate * 30) +private let iterations = 5 + +private func appendLittleEndian(_ value: T, to data: inout Data) { + data.append(contentsOf: withUnsafeBytes(of: value.littleEndian) { Array($0) }) +} + +private func appendWaveHeader(sampleCount: Int, sampleRate: Double, to data: inout Data) { + let frameCount = UInt32(sampleCount) + let bytesPerSample: UInt32 = 2 + let channels: UInt32 = 1 + let dataSize = frameCount * bytesPerSample * channels + let fileSize = 36 + dataSize + + data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) + appendLittleEndian(UInt32(fileSize), to: &data) + data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) + + data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) + appendLittleEndian(UInt32(16), to: &data) + appendLittleEndian(UInt16(1), to: &data) + appendLittleEndian(UInt16(channels), to: &data) + appendLittleEndian(UInt32(sampleRate), to: &data) + appendLittleEndian(UInt32(sampleRate) * channels * bytesPerSample, to: &data) + appendLittleEndian(UInt16(UInt16(channels) * UInt16(bytesPerSample)), to: &data) + appendLittleEndian(UInt16(bytesPerSample * 8), to: &data) + + data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) + appendLittleEndian(UInt32(dataSize), to: &data) +} + +@inline(never) +private func legacyWaveData(samples: [Float], sampleRate: Double) -> Data { + let dataSize = UInt32(samples.count) * 2 + var data = Data(capacity: Int(44 + dataSize)) + appendWaveHeader(sampleCount: samples.count, sampleRate: sampleRate, to: &data) + + for sample in samples { + let clamped = max(-1.0, min(1.0, sample)) + let intVal = Int16(clamped * Float(Int16.max)) + data.append(contentsOf: withUnsafeBytes(of: Int16(littleEndian: intVal)) { Array($0) }) + } + + return data +} + +@inline(never) +private func bulkWaveData(samples: [Float], sampleRate: Double) -> Data { + let dataSize = UInt32(samples.count) * 2 + var data = Data(capacity: Int(44 + dataSize)) + appendWaveHeader(sampleCount: samples.count, sampleRate: sampleRate, to: &data) + + var pcmSamples = [Int16]() + pcmSamples.reserveCapacity(samples.count) + for sample in samples { + let clamped = max(-1.0, min(1.0, sample)) + let intVal = Int16(clamped * Float(Int16.max)) + pcmSamples.append(Int16(littleEndian: intVal)) + } + pcmSamples.withUnsafeBytes { rawBuffer in + data.append(rawBuffer.bindMemory(to: UInt8.self)) + } + + return data +} + +private func uint16LE(_ data: Data, at offset: Int) -> UInt16 { + data.withUnsafeBytes { rawBuffer in + rawBuffer.loadUnaligned(fromByteOffset: offset, as: UInt16.self).littleEndian + } +} + +private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { + data.withUnsafeBytes { rawBuffer in + rawBuffer.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian + } +} + +@inline(never) +private func measure(label: String, samples: [Float], block: ([Float], Double) -> Data) -> (label: String, ms: Double, checksum: Int) { + var generated: [Data] = [] + generated.reserveCapacity(iterations) + + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0.. Float in + let phase = Float(index % 1024) / 1024.0 + return (phase * 2.2) - 1.1 +} + +let legacy = legacyWaveData(samples: samples, sampleRate: sampleRate) +let bulk = bulkWaveData(samples: samples, sampleRate: sampleRate) +precondition(legacy == bulk, "bulk output must match legacy bytes for 30-second payload") +precondition(bulk.count == 44 + sampleCount * 2) +precondition(uint32LE(bulk, at: 4) == UInt32(36 + sampleCount * 2)) +precondition(uint32LE(bulk, at: 40) == UInt32(sampleCount * 2)) + +let legacyTiming = measure(label: "per-sample append", samples: samples, block: legacyWaveData) +let bulkTiming = measure(label: "bulk Int16 append", samples: samples, block: bulkWaveData) + +print("samples=\(sampleCount) sampleRate=\(Int(sampleRate)) iterations=\(iterations)") +print("validated byte-for-byte WAV output and header/data sizes") +print("\(legacyTiming.label): \(String(format: "%.2f", legacyTiming.ms)) ms avg checksum=\(legacyTiming.checksum)") +print("\(bulkTiming.label): \(String(format: "%.2f", bulkTiming.ms)) ms avg checksum=\(bulkTiming.checksum)") +print("speedup: \(String(format: "%.2fx", legacyTiming.ms / bulkTiming.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-background-no-view-dedupe.mjs b/scripts/test-background-no-view-dedupe.mjs new file mode 100644 index 00000000..f2ef7296 --- /dev/null +++ b/scripts/test-background-no-view-dedupe.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import test from 'node:test'; +import vm from 'vm'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + 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: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const { + enqueueBackgroundNoViewRun, + getBackgroundNoViewRunIdentity, + removeBackgroundNoViewRun, +} = loadTsModule('src/renderer/src/utils/background-no-view-runs.ts'); + +function runIdentity(bundle) { + return `${bundle.extensionName || bundle.extName || ''}/${bundle.commandName || bundle.cmdName || ''}`; +} + +function legacyQueueNoViewBundleRun(prev, bundle, launchType = 'background', reportStatus = false, now = Date.now) { + const runId = `${runIdentity(bundle)}/${now()}`; + return [...prev, { runId, bundle, launchType, reportStatus }]; +} + +function simulateRepeatedBackgroundTicks({ enqueue, ticks, intervalMs }) { + const bundle = { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extensionName: 'widgets', + commandName: 'refresh', + }; + let runs = []; + const tickTimes = Array.from({ length: ticks }, (_, index) => index * intervalMs); + for (const elapsedMs of tickTimes) { + runs = enqueue(runs, bundle, 'background', false, () => elapsedMs); + } + return { + runs, + ticks, + intervalMs, + elapsedMs: tickTimes.at(-1) ?? 0, + }; +} + +function simulateDedupedBackgroundTicks({ ticks, intervalMs }) { + const bundle = { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extName: 'widgets', + cmdName: 'refresh', + extensionName: 'widgets', + commandName: 'refresh', + }; + let runs = []; + let enqueued = 0; + const tickTimes = Array.from({ length: ticks }, (_, index) => index * intervalMs); + for (const elapsedMs of tickTimes) { + const result = enqueueBackgroundNoViewRun(runs, bundle, 'background', false, () => elapsedMs); + runs = result.runs; + if (result.enqueued) enqueued += 1; + } + return { + runs, + enqueued, + skipped: ticks - enqueued, + ticks, + intervalMs, + elapsedMs: tickTimes.at(-1) ?? 0, + }; +} + +function testBundle(overrides = {}) { + return { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extName: 'widgets', + cmdName: 'refresh', + ...overrides, + }; +} + +test('baseline: legacy background no-view queue accepts overlapping duplicate ticks', () => { + const metrics = simulateRepeatedBackgroundTicks({ + enqueue: legacyQueueNoViewBundleRun, + ticks: 3, + intervalMs: 25, + }); + + console.log( + `[baseline] background no-view duplicate ticks: ticks=${metrics.ticks} intervalMs=${metrics.intervalMs} elapsedMs=${metrics.elapsedMs} queued=${metrics.runs.length}` + ); + + assert.equal(metrics.runs.length, 3); + assert.deepEqual(metrics.runs.map((run) => runIdentity(run.bundle)), [ + 'widgets/refresh', + 'widgets/refresh', + 'widgets/refresh', + ]); +}); + +test('background no-view queue dedupes overlapping ticks for the same extension command', () => { + const metrics = simulateDedupedBackgroundTicks({ + ticks: 3, + intervalMs: 25, + }); + + console.log( + `[after] background no-view duplicate ticks: ticks=${metrics.ticks} intervalMs=${metrics.intervalMs} elapsedMs=${metrics.elapsedMs} queued=${metrics.runs.length} skipped=${metrics.skipped}` + ); + + assert.equal(metrics.enqueued, 1); + assert.equal(metrics.skipped, 2); + assert.equal(metrics.runs.length, 1); + assert.equal(getBackgroundNoViewRunIdentity(metrics.runs[0].bundle), 'widgets/refresh'); +}); + +test('background no-view identity matches legacy and hydrated bundle fields', () => { + const legacyFields = testBundle(); + const hydratedFields = { + code: legacyFields.code, + mode: legacyFields.mode, + title: legacyFields.title, + extensionName: 'widgets', + commandName: 'refresh', + }; + + const first = enqueueBackgroundNoViewRun([], legacyFields, 'background', false, () => 0); + assert.equal(first.enqueued, true); + + const duplicate = enqueueBackgroundNoViewRun(first.runs, hydratedFields, 'background', false, () => 25); + assert.equal(duplicate.enqueued, false); + assert.equal(duplicate.runs.length, 1); +}); + +test('user-initiated no-view launches are not deduped', () => { + const bundle = testBundle(); + let runs = []; + for (const elapsedMs of [0, 25, 50]) { + const result = enqueueBackgroundNoViewRun(runs, bundle, 'userInitiated', false, () => elapsedMs); + assert.equal(result.enqueued, true); + runs = result.runs; + } + + assert.equal(runs.length, 3); + assert.deepEqual(Array.from(runs, (run) => run.launchType), [ + 'userInitiated', + 'userInitiated', + 'userInitiated', + ]); +}); + +test('background no-view launch skips while same command is already in flight', () => { + const bundle = testBundle({ + extensionName: 'widgets', + commandName: 'refresh', + }); + const userLaunch = enqueueBackgroundNoViewRun([], bundle, 'userInitiated', false, () => 0); + assert.equal(userLaunch.enqueued, true); + + const backgroundTick = enqueueBackgroundNoViewRun(userLaunch.runs, bundle, 'background', false, () => 25); + assert.equal(backgroundTick.enqueued, false); + assert.equal(backgroundTick.runs.length, 1); + + const explicitLaunch = enqueueBackgroundNoViewRun(backgroundTick.runs, bundle, 'userInitiated', false, () => 50); + assert.equal(explicitLaunch.enqueued, true); + assert.equal(explicitLaunch.runs.length, 2); +}); + +test('clearing a background no-view run allows the next background tick to enqueue', () => { + const bundle = testBundle({ + extensionName: 'widgets', + commandName: 'refresh', + }); + + for (const reason of ['success', 'error', 'unmount']) { + const first = enqueueBackgroundNoViewRun([], bundle, 'background', false, () => 0); + assert.equal(first.enqueued, true, reason); + + const overlapping = enqueueBackgroundNoViewRun(first.runs, bundle, 'background', false, () => 25); + assert.equal(overlapping.enqueued, false, reason); + assert.equal(overlapping.runs.length, 1, reason); + + const cleared = removeBackgroundNoViewRun(overlapping.runs, overlapping.runs[0].runId); + assert.equal(cleared.length, 0, reason); + + const afterClear = enqueueBackgroundNoViewRun(cleared, bundle, 'background', false, () => 50); + assert.equal(afterClear.enqueued, true, reason); + assert.equal(afterClear.runs.length, 1, reason); + } +}); diff --git a/scripts/test-background-refresh-timer-diff.mjs b/scripts/test-background-refresh-timer-diff.mjs new file mode 100644 index 00000000..691f3b20 --- /dev/null +++ b/scripts/test-background-refresh-timer-diff.mjs @@ -0,0 +1,230 @@ +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'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const helperPath = path.join(rootDir, 'src/renderer/src/hooks/backgroundRefreshTimers.ts'); + +function loadHelperModule() { + const source = fs.readFileSync(helperPath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + fileName: helperPath, + }); + const module = { exports: {} }; + + vm.runInNewContext( + compiled.outputText, + { + exports: module.exports, + module, + require: (specifier) => { + throw new Error(`Unexpected runtime import while loading timer helper: ${specifier}`); + }, + }, + { filename: helperPath } + ); + + return module.exports; +} + +const { + getBackgroundRefreshTimerDescriptors, + reconcileBackgroundRefreshTimers, + clearBackgroundRefreshTimers, +} = loadHelperModule(); + +function parseIntervalToMs(interval) { + return { + '1m': 60_000, + '5m': 300_000, + '10m': 600_000, + }[interval] ?? null; +} + +function extensionCommand(overrides = {}) { + return { + id: 'extension-weather-current', + title: 'Current Weather', + category: 'extension', + path: 'weather/current', + mode: 'no-view', + interval: '1m', + ...overrides, + }; +} + +function inlineScriptCommand(overrides = {}) { + return { + id: 'script-stock-ticker', + title: 'Stock Ticker', + category: 'script', + path: '/Users/example/Scripts/stocks.sh', + mode: 'inline', + interval: '5m', + ...overrides, + }; +} + +function descriptors(commands) { + return getBackgroundRefreshTimerDescriptors(commands, parseIntervalToMs); +} + +function legacyReconcile(commands, activeTimers) { + const cleared = activeTimers.length; + activeTimers.length = 0; + + for (const descriptor of descriptors(commands)) { + activeTimers.push(descriptor.key); + } + + return { created: activeTimers.length, cleared }; +} + +function createHarness() { + const timers = new Map(); + const created = []; + const cleared = []; + let nextTimerId = 1; + + return { + timers, + created, + cleared, + reconcile(commands) { + return reconcileBackgroundRefreshTimers({ + timers, + descriptors: descriptors(commands), + createTimer(descriptor) { + const timerId = nextTimerId++; + created.push({ timerId, key: descriptor.key, commandId: descriptor.command.id }); + return timerId; + }, + clearTimer(timerId) { + cleared.push(timerId); + }, + }); + }, + clearAll() { + return clearBackgroundRefreshTimers(timers, (timerId) => { + cleared.push(timerId); + }); + }, + }; +} + +function activeTimerIds(harness) { + return Array.from(harness.timers.values()).map((entry) => entry.timerId); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +test('Background refresh timer diff', async (t) => { + await t.test('baseline legacy strategy clears and recreates unchanged background commands', () => { + const activeTimers = []; + const initialCommands = [ + extensionCommand(), + inlineScriptCommand(), + { id: 'app-terminal', title: 'Terminal', category: 'app' }, + ]; + const refreshedCommands = [ + extensionCommand({ subtitle: 'Updated metadata' }), + inlineScriptCommand({ subtitle: 'AAPL 210.00' }), + { id: 'app-terminal', title: 'Terminal', category: 'app', subtitle: 'Terminal.app' }, + ]; + + const first = legacyReconcile(initialCommands, activeTimers); + const second = legacyReconcile(refreshedCommands, activeTimers); + + console.log( + `[background-refresh baseline] unchanged update legacy created=${second.created} cleared=${second.cleared}` + ); + assert.deepEqual(first, { created: 2, cleared: 0 }); + assert.deepEqual(second, { created: 2, cleared: 2 }); + }); + + await t.test('unchanged background commands retain their timers across command-list updates', () => { + const harness = createHarness(); + const initial = harness.reconcile([ + extensionCommand(), + inlineScriptCommand(), + { id: 'app-terminal', title: 'Terminal', category: 'app' }, + ]); + const timerIdsBeforeUpdate = activeTimerIds(harness); + + const update = harness.reconcile([ + extensionCommand({ subtitle: 'Updated metadata' }), + inlineScriptCommand({ subtitle: 'AAPL 210.00' }), + { id: 'app-terminal', title: 'Terminal', category: 'app', subtitle: 'Terminal.app' }, + ]); + + console.log( + `[background-refresh keyed] unchanged update created=${update.created} retained=${update.retained} cleared=${update.cleared}` + ); + assert.deepEqual(plain(initial), { created: 2, retained: 0, cleared: 0 }); + assert.deepEqual(plain(update), { created: 0, retained: 2, cleared: 0 }); + assert.deepEqual(activeTimerIds(harness), timerIdsBeforeUpdate); + assert.equal(harness.created.length, 2); + assert.equal(harness.cleared.length, 0); + }); + + await t.test('added background commands create timers and removed commands clear timers', () => { + const harness = createHarness(); + const extension = extensionCommand(); + const script = inlineScriptCommand(); + + assert.deepEqual(plain(harness.reconcile([extension])), { created: 1, retained: 0, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([extension, script])), { created: 1, retained: 1, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([script])), { created: 0, retained: 1, cleared: 1 }); + assert.deepEqual(activeTimerIds(harness), [2]); + assert.deepEqual(harness.cleared, [1]); + }); + + await t.test('changed background command identity fields restart only the changed timer', () => { + const harness = createHarness(); + const extension = extensionCommand(); + const script = inlineScriptCommand(); + + assert.deepEqual(plain(harness.reconcile([extension, script])), { created: 2, retained: 0, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([extensionCommand({ interval: '5m' }), script])), { + created: 1, + retained: 1, + cleared: 1, + }); + assert.deepEqual(activeTimerIds(harness), [2, 3]); + assert.deepEqual(harness.cleared, [1]); + + assert.deepEqual(plain(harness.reconcile([extensionCommand({ interval: '5m', mode: 'menu-bar' }), script])), { + created: 1, + retained: 1, + cleared: 1, + }); + assert.deepEqual(activeTimerIds(harness), [2, 4]); + assert.deepEqual(harness.cleared, [1, 3]); + }); + + await t.test('unmount cleanup clears all remaining timers once', () => { + const harness = createHarness(); + + assert.deepEqual(plain(harness.reconcile([extensionCommand(), inlineScriptCommand()])), { + created: 2, + retained: 0, + cleared: 0, + }); + assert.equal(harness.clearAll(), 2); + assert.deepEqual(activeTimerIds(harness), []); + assert.deepEqual(harness.cleared, [1, 2]); + }); +}); diff --git a/scripts/test-browser-search-lag-fix.mjs b/scripts/test-browser-search-lag-fix.mjs index bff0d368..ee2f158b 100644 --- a/scripts/test-browser-search-lag-fix.mjs +++ b/scripts/test-browser-search-lag-fix.mjs @@ -3,6 +3,11 @@ 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 files = { history: fs.readFileSync('src/main/browser-search-history.ts', 'utf8'), @@ -24,9 +29,26 @@ function assertNotIncludes(source, needle) { } test('Browser search lag fix', async (t) => { - await t.test('main history module exports required functions', () => { - assertIncludes(files.history, 'getBrowserSearchRevision'); - assertIncludes(files.history, 'getBrowserSearchStats'); + // Baseline: this used to grep browser-search-history.ts for export names, + // which could not prove the module or its relative imports actually execute. + await t.test('main history module imports exported browser-search API', async () => { + const history = await importTs(path.join(root, 'src/main/browser-search-history.ts')); + + assert.equal(typeof history.getBrowserSearchRevision, 'function'); + assert.equal(typeof history.getBrowserSearchStats, 'function'); + assert.equal(typeof history.resolveInput, 'function'); + + const url = history.resolveInput('example.com'); + assert.equal(url?.type, 'url'); + assert.equal(url?.url, 'https://example.com/'); + assert.equal(url?.host, 'example.com'); + + const search = history.resolveInput('browser search lag'); + assert.equal(search?.type, 'search'); + assert.equal(search?.url, 'https://www.google.com/search?q=browser%20search%20lag'); + }); + + await t.test('profile bookmark import keeps dedupe state', () => { assertIncludes(files.history, 'seenBookmarkKeys'); assertIncludes(files.history, 'existingBookmarkByKey'); }); @@ -57,8 +79,12 @@ test('Browser search lag fix', async (t) => { assertIncludes(files.hook, 'refreshEntriesIfStale'); assertIncludes(files.hook, 'historyByTimeEntryIds'); assertIncludes(files.hook, 'bookmarksByBrowserOrderEntryIds'); + assertIncludes(files.hook, 'tokenPrefixEntryIds'); + assertIncludes(files.hook, 'tokenTrigramEntryIds'); + assertIncludes(files.hook, 'getIndexedEntryCandidateIds'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096'); + assertIncludes(files.hook, '__browserSearchTestAccess'); }); await t.test('local commands use refreshEntriesIfStale not refreshEntries', () => { diff --git a/scripts/test-browser-search-performance.mjs b/scripts/test-browser-search-performance.mjs new file mode 100644 index 00000000..a34a7d4c --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,475 @@ +#!/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'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const FIXED_NOW = Date.UTC(2026, 6, 3, 12, 0, 0); +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; +const IS_PERF_REPORT = IS_PERF_CI + || process.env.SUPERCMD_PERF_REPORT === '1' + || process.argv.includes('--report') + || process.argv.includes('--json'); + +class FixedDate extends Date { + constructor(...args) { + super(...(args.length ? args : [FIXED_NOW])); + } + + static now() { + return FIXED_NOW; + } +} + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + 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, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return /\.[cm]?[tj]sx?$/.test(nextPath) ? loadTsModule(nextPath) : require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Intl, + Date: FixedDate, + Math, + String, + Number, + Boolean, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function buildSyntheticBrowserData(options = {}) { + const { + historyCount = 4_000, + bookmarkCount = 800, + tabCount = 120, + } = options; + const profiles = ['chrome:Default', 'chrome:Work', 'arc:Default', 'brave:Default']; + const topics = ['github', 'docs', 'linear', 'notion', 'calendar', 'supercmd', 'typescript', 'electron', 'raycast', 'browser']; + const entries = []; + const tabs = []; + + for (let index = 0; index < historyCount; index += 1) { + const topic = topics[index % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}${index % 700}.example.com`; + entries.push({ + id: `h-${index}`, + type: 'url', + query: `${topic} history page ${index}`, + url: `https://${host}/workspace/${index % 200}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 371_000, + useCount: (index % 23) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + }); + } + + for (let index = 0; index < bookmarkCount; index += 1) { + const topic = topics[(index * 3) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-bookmark${index % 600}.example.com`; + entries.push({ + id: `b-${index}`, + type: 'bookmark', + query: `${topic} bookmark reference ${index}`, + url: `https://${host}/saved/${index % 300}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 997_000, + useCount: (index % 11) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + bookmarkFolder: `Folder ${index % 25}`, + bookmarkOrder: index, + }); + } + + const nicknameBookmark = { + id: 'b-nickname-github', + type: 'bookmark', + query: 'GitHub Pull Requests', + url: 'https://github.com/SuperCmdLabs/SuperCmd/pulls', + host: 'github.com', + lastUsedAt: FIXED_NOW, + useCount: 40, + source: 'chrome', + sourceProfileId: 'chrome:Default', + sourceProfileName: 'Default', + bookmarkFolder: 'Development', + bookmarkOrder: -1, + }; + entries.push(nicknameBookmark); + + for (let index = 0; index < tabCount; index += 1) { + const topic = topics[(index * 7) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-tab${index % 120}.example.com`; + tabs.push({ + id: `t-${index}`, + browserId: source, + browserName: source, + profileId: profile.split(':')[1], + profileSourceId: profile, + profileName: profile.split(':')[1], + windowId: String(index % 8), + windowOrdinal: index % 8, + tabId: String(index), + tabIndex: index % 40, + favIconUrl: '', + title: `${topic} active tab ${index}`, + url: `https://${host}/open/${index}`, + host, + active: index % 37 === 0, + windowLastFocusedAt: FIXED_NOW - (index % 120) * 60_000, + updatedAt: FIXED_NOW - index * 60_000, + }); + } + + const nicknames = [{ + source: nicknameBookmark.source, + sourceProfileId: nicknameBookmark.sourceProfileId, + url: nicknameBookmark.url, + nickname: 'gh', + }]; + + return { entries, tabs, nicknames }; +} + +function resultSignature(results) { + return results.map((result) => [ + result.id, + result.kind, + result.title, + result.url, + result.completion || '', + result.matchKind || '', + Boolean(result.nicknameMatch), + ]); +} + +function measureAverageMs(iterations, queries, fn) { + const byQuery = {}; + for (const query of queries) { + const start = performance.now(); + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const results = fn(query); + checksum += results.length + (results[0]?.id?.length || 0); + } + byQuery[query] = { + avgMs: (performance.now() - start) / iterations, + checksum, + }; + } + return byQuery; +} + +async function measureBlockedTurn(fn) { + const scheduledAt = performance.now(); + const delayPromise = new Promise((resolve) => { + setTimeout(() => resolve(performance.now() - scheduledAt), 0); + }); + const start = performance.now(); + const result = fn(); + const durationMs = performance.now() - start; + const eventLoopDelayMs = await delayPromise; + return { + result, + durationMs, + eventLoopDelayMs, + }; +} + +function browserResultGroups() { + return [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; +} + +function browserQueries() { + return [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; +} + +function assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, +}) { + assert.deepEqual( + resultSignature(getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60)), + resultSignature(getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60)), + `ranked results should match the full scan for ${query}` + ); + assert.deepEqual( + resultSignature(getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true })), + resultSignature(getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true })), + `ordered results should match the full scan for ${query}` + ); +} + +function roundReportValues(values) { + return Object.fromEntries( + Object.entries(values).map(([query, item]) => [query, { + avgMs: Number(item.avgMs.toFixed(2)), + checksum: item.checksum, + }]) + ); +} + +function printBrowserSearchPerfReport(report) { + if (!IS_PERF_REPORT) return; + console.log(JSON.stringify({ browserSearchPerf: report }, null, 2)); +} + +function budgetEntry(actual, budget) { + return { + actualMs: actual, + budgetMs: budget, + budgetUsedPct: Number(((actual / budget) * 100).toFixed(1)), + }; +} + +test('browser search indexed harness preserves full-scan result order', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = browserResultGroups(); + const queries = browserQueries(); + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } +}); + +test('browser search indexed harness preserves profile filtering', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, filterBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 4 }, + { kind: 'open-tab', limit: 4 }, + { kind: 'history', limit: 4 }, + ]; + const profiles = [ + { id: 'chrome:Default', displayName: 'Chrome Default', detectedName: 'Default', profileId: 'Default', browserId: 'chrome', browserName: 'Chrome', order: 0 }, + { id: 'chrome:Work', displayName: 'Chrome Work', detectedName: 'Work', profileId: 'Work', browserId: 'chrome', browserName: 'Chrome', order: 1 }, + { id: 'arc:Default', displayName: 'Arc Default', detectedName: 'Default', profileId: 'Default', browserId: 'arc', browserName: 'Arc', order: 2 }, + { id: 'brave:Default', displayName: 'Brave Default', detectedName: 'Default', profileId: 'Default', browserId: 'brave', browserName: 'Brave', order: 3 }, + ]; + const filters = { + 'open-tab': ['chrome:Default'], + bookmark: ['chrome:Default'], + history: ['chrome:Default'], + }; + + const filtered = filterBrowserResults( + getRankedBrowserResults('github', groups, entries, entryIndex, tabs, nicknames, 60), + filters, + profiles + ); + + assert.ok(filtered.length > 0, 'profile-filtered search should keep matching results'); + assert.ok( + filtered.every((result) => !result.sourceProfileId || result.sourceProfileId === 'chrome:Default'), + 'profile filtering should only keep enabled profile IDs' + ); +}); + +test('browser search indexed harness stays under generous query thresholds', async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = browserResultGroups(); + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + const rankedIndexed = measureAverageMs(15, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + ); + const orderedIndexed = measureAverageMs(15, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedMaxMs = Math.max(...Object.values(rankedIndexed).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedIndexed).map((item) => item.avgMs)); + const report = { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedAvgMs: roundReportValues(rankedIndexed), + orderedIndexedAvgMs: roundReportValues(orderedIndexed), + }; + + if (IS_PERF_REPORT) { + report.rankedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + )); + report.orderedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + )); + } + printBrowserSearchPerfReport(report); + + assert.ok(indexMeasurement.durationMs < 1_500, `index build should stay below 1500ms, got ${indexMeasurement.durationMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < 120, `ranked indexed search should stay below 120ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < 120, `ordered indexed search should stay below 120ms, got ${orderedMaxMs.toFixed(2)}ms`); +}); + +test('browser search perf CI covers large indexed responsiveness budgets', { skip: !IS_PERF_CI }, async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const groups = browserResultGroups(); + const queries = ['github', 'supercmd', 'electron workspace', 'bookmark reference 42', 'https://typescript', 'gh']; + const datasets = [ + { + label: '25k', + options: { historyCount: 21_000, bookmarkCount: 3_999, tabCount: 240 }, + budgets: { indexMs: 3_500, eventLoopDelayMs: 4_000, queryAvgMs: 180, queryEventLoopDelayMs: 4_000 }, + }, + { + label: '50k', + options: { historyCount: 45_000, bookmarkCount: 4_999, tabCount: 360 }, + budgets: { indexMs: 7_000, eventLoopDelayMs: 7_500, queryAvgMs: 300, queryEventLoopDelayMs: 8_000 }, + }, + ]; + const reports = []; + + for (const dataset of datasets) { + const { entries, tabs, nicknames } = buildSyntheticBrowserData(dataset.options); + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } + + const rankedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + )); + const orderedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + )); + const rankedMaxMs = Math.max(...Object.values(rankedMeasurement.result).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedMeasurement.result).map((item) => item.avgMs)); + + reports.push({ + dataset: { label: dataset.label, entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedMaxAvgMs: Number(rankedMaxMs.toFixed(2)), + orderedIndexedMaxAvgMs: Number(orderedMaxMs.toFixed(2)), + rankedEventLoopDelayMs: Number(rankedMeasurement.eventLoopDelayMs.toFixed(2)), + orderedEventLoopDelayMs: Number(orderedMeasurement.eventLoopDelayMs.toFixed(2)), + budgets: { + indexMs: budgetEntry(Number(indexMeasurement.durationMs.toFixed(2)), dataset.budgets.indexMs), + indexEventLoopDelayMs: budgetEntry(Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.eventLoopDelayMs), + rankedIndexedMaxAvgMs: budgetEntry(Number(rankedMaxMs.toFixed(2)), dataset.budgets.queryAvgMs), + orderedIndexedMaxAvgMs: budgetEntry(Number(orderedMaxMs.toFixed(2)), dataset.budgets.queryAvgMs), + rankedEventLoopDelayMs: budgetEntry(Number(rankedMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.queryEventLoopDelayMs), + orderedEventLoopDelayMs: budgetEntry(Number(orderedMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.queryEventLoopDelayMs), + }, + }); + + assert.ok(indexMeasurement.durationMs < dataset.budgets.indexMs, `${dataset.label} index build should stay below ${dataset.budgets.indexMs}ms, got ${indexMeasurement.durationMs.toFixed(2)}ms`); + assert.ok(indexMeasurement.eventLoopDelayMs < dataset.budgets.eventLoopDelayMs, `${dataset.label} index event-loop delay should stay below ${dataset.budgets.eventLoopDelayMs}ms, got ${indexMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ranked indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ordered indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${orderedMaxMs.toFixed(2)}ms`); + assert.ok(rankedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ranked query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${rankedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(orderedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ordered query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${orderedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + } + + printBrowserSearchPerfReport({ largeDatasets: reports }); +}); diff --git a/scripts/test-camera-capture-single-encode.mjs b/scripts/test-camera-capture-single-encode.mjs new file mode 100644 index 00000000..0a9ced86 --- /dev/null +++ b/scripts/test-camera-capture-single-encode.mjs @@ -0,0 +1,155 @@ +#!/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 mainPath = path.join(root, 'src/main/main.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();')); + }); +}); + +test('Camera capture save path avoids redundant mkdir subprocess', async (t) => { + await t.test('camera capture writes image bytes without shelling out for mkdir', () => { + const source = fs.readFileSync(cameraExtensionPath, 'utf8'); + + assert.ok(!source.includes('/bin/mkdir')); + assert.ok(!source.includes("['-p', saveDir]")); + assert.ok(source.includes('const saveDir = homeDir ? `${homeDir}/Pictures/SuperCmd Captures` : \'/tmp/SuperCmd Captures\';')); + assert.ok(source.includes('const savePath = `${saveDir}/supercmd-capture-${timestamp}.png`;')); + assert.ok(source.includes('const bytes = new Uint8Array(await captureBlob.arrayBuffer());')); + assert.ok(source.includes('await window.electron.fsWriteBinaryFile(savePath, bytes);')); + }); + + await t.test('fsWriteBinaryFile IPC creates parent directories recursively', () => { + const source = fs.readFileSync(mainPath, 'utf8'); + const mkdirLine = 'await fs.promises.mkdir(nodePath.dirname(filePath), { recursive: true });'; + const writeLine = 'await fs.promises.writeFile(filePath, Buffer.from(data));'; + + assert.ok(source.includes("ipcMain.handle('fs-write-binary-file'")); + assert.ok(source.includes(mkdirLine)); + assert.ok(source.includes(writeLine)); + assert.ok(source.indexOf(mkdirLine) < source.indexOf(writeLine)); + }); +}); diff --git a/scripts/test-canvas-asset-serving.mjs b/scripts/test-canvas-asset-serving.mjs new file mode 100644 index 00000000..dad70649 --- /dev/null +++ b/scripts/test-canvas-asset-serving.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import * as esbuild from 'esbuild'; +import { importTs } from './lib/ts-import.mjs'; + +async function importCanvasStore(root) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-canvas-store-bundle-')); + const bundlePath = path.join(tmpDir, 'canvas-store.mjs'); + try { + await esbuild.build({ + entryPoints: [path.join(root, 'src/main/canvas-store.ts')], + outfile: bundlePath, + bundle: true, + format: 'esm', + platform: 'node', + logLevel: 'silent', + plugins: [{ + name: 'electron-test-stub', + setup(build) { + build.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron-test-stub', namespace: 'electron-test-stub' })); + build.onLoad({ filter: /.*/, namespace: 'electron-test-stub' }, () => ({ + loader: 'js', + contents: ` + export const app = { getPath: () => process.env.SUPERCMD_TEST_USER_DATA || process.cwd() }; + export const dialog = { showSaveDialog: async () => ({ canceled: true }) }; + export class BrowserWindow {} + `, + })); + }, + }], + }); + return await import(pathToFileURL(bundlePath).href); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +test('canvas sc-asset serving helpers', async (t) => { + const root = process.cwd(); + const protocol = await importTs(path.join(root, 'src/main/sc-asset-protocol.ts')); + + await t.test('resolves canvas-lib files under the library root with content and cache headers', () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd canvas lib'); + const resolved = protocol.resolveCanvasLibAssetRequest( + 'sc-asset://canvas-lib/nested/excalidraw-bundle.js', + canvasLibDir + ); + + assert.equal(resolved.ok, true); + assert.equal(resolved.asset.filePath, path.join(canvasLibDir, 'nested', 'excalidraw-bundle.js')); + assert.equal(resolved.asset.headers['Content-Type'], 'application/javascript'); + assert.equal(resolved.asset.headers['Cache-Control'], 'public, max-age=31536000, immutable'); + }); + + await t.test('assigns expected content types and falls back to octet-stream', () => { + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/style.css'), 'text/css'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/icon.svg'), 'image/svg+xml'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/font.woff2'), 'font/woff2'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/file.bin'), 'application/octet-stream'); + }); + + await t.test('rejects empty, malformed, and escaping canvas-lib paths', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd-canvas-lib'); + + const empty = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/', canvasLibDir); + assert.equal(empty.ok, false); + assert.equal(empty.response.status, 400); + + const malformed = protocol.resolveCanvasLibAssetRequest('not a url', canvasLibDir); + assert.equal(malformed.ok, false); + assert.equal(malformed.response.status, 400); + + const badEscape = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/%E0%A4%A', canvasLibDir); + assert.equal(badEscape.ok, false); + assert.equal(badEscape.response.status, 400); + + const traversal = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/%2e%2e%2foutside.js', canvasLibDir); + assert.equal(traversal.ok, false); + assert.equal(traversal.response.status, 404); + }); + + await t.test('serves through the provided file fetcher without buffering in the helper', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd canvas lib'); + let fetchedUrl = ''; + + const response = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/excalidraw-bundle.css', + canvasLibDir, + async (fileUrl) => { + fetchedUrl = fileUrl; + return new Response('css body', { status: 200, headers: { 'Content-Type': 'text/plain' } }); + } + ); + + assert.equal(fetchedUrl, pathToFileURL(path.join(canvasLibDir, 'excalidraw-bundle.css')).toString()); + assert.equal(response.status, 200); + assert.equal(response.headers.get('Content-Type'), 'text/css'); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=31536000, immutable'); + assert.equal(await response.text(), 'css body'); + }); + + await t.test('returns 404 when the file-backed fetch fails or misses', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd-canvas-lib'); + + const missing = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/missing.js', + canvasLibDir, + async () => new Response('', { status: 404 }) + ); + assert.equal(missing.status, 404); + + const failed = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/missing.js', + canvasLibDir, + async () => { + throw new Error('missing'); + } + ); + assert.equal(failed.status, 404); + }); +}); + +test('canvas store async scene and thumbnail reads', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-canvas-store-')); + const previousUserData = process.env.SUPERCMD_TEST_USER_DATA; + process.env.SUPERCMD_TEST_USER_DATA = tmpDir; + + try { + const root = process.cwd(); + const store = await importCanvasStore(root); + const dataDir = path.join(tmpDir, 'canvas', 'data'); + await fs.mkdir(dataDir, { recursive: true }); + + const scene = { + elements: [{ id: 'a' }], + appState: { viewBackgroundColor: '#fff' }, + files: { image: { id: 'image' } }, + }; + await fs.writeFile(path.join(dataDir, 'scene-id.excalidraw'), JSON.stringify(scene), 'utf-8'); + await fs.writeFile(path.join(dataDir, 'scene-id.thumb.svg'), '', 'utf-8'); + await fs.writeFile(path.join(dataDir, 'invalid.excalidraw'), '{', 'utf-8'); + + await assert.doesNotReject(store.getSceneAsync('scene-id')); + assert.deepEqual(await store.getSceneAsync('scene-id'), scene); + assert.deepEqual(await store.getSceneAsync('missing-id'), { elements: [], appState: {}, files: {} }); + + const originalConsoleError = console.error; + console.error = () => {}; + try { + assert.deepEqual(await store.getSceneAsync('invalid'), { elements: [], appState: {}, files: {} }); + } finally { + console.error = originalConsoleError; + } + + assert.equal(await store.getThumbnailAsync('scene-id'), ''); + assert.equal(await store.getThumbnailAsync('missing-id'), null); + } finally { + if (previousUserData === undefined) { + delete process.env.SUPERCMD_TEST_USER_DATA; + } else { + process.env.SUPERCMD_TEST_USER_DATA = previousUserData; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-clipboard-history-persistence.mjs b/scripts/test-clipboard-history-persistence.mjs new file mode 100644 index 00000000..c6076c41 --- /dev/null +++ b/scripts/test-clipboard-history-persistence.mjs @@ -0,0 +1,133 @@ +#!/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, '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, 'historySaveTimer = setTimeout'); + 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, '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-cloud-media-buffering.mjs b/scripts/test-cloud-media-buffering.mjs new file mode 100644 index 00000000..93420cbc --- /dev/null +++ b/scripts/test-cloud-media-buffering.mjs @@ -0,0 +1,475 @@ +#!/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 vm from 'node:vm'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { createRequire } from 'node:module'; + +const requireFromHere = createRequire(import.meta.url); +const mainSource = fs.readFileSync(path.resolve('src/main/main.ts'), 'utf8'); +const providerSource = extractSourceBetween( + mainSource, + 'type BufferedRequestPart = Buffer;', + 'function fetchElevenLabsVoices', +); + +test('ElevenLabs STT streams multipart upload parts without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(6 * 1024 * 1024, 0x65); + const https = createFakeHttps({ responseChunks: [Buffer.from('{"text":" eleven transcript "}')] }); + const { transcribeAudioWithElevenLabs } = await loadMainMediaProviders({ https }); + + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudioWithElevenLabs({ + audioBuffer, + apiKey: 'eleven-key', + model: 'scribe_v1', + language: 'en', + mimeType: 'audio/wav', + })); + + assert.equal(result, 'eleven transcript'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedElevenLabsSttParts({ + audioBuffer, + boundary, + language: 'en', + mimeType: 'audio/wav', + model: 'scribe_v1', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + apiKey: req.options.headers['xi-api-key'], + contentLength: req.options.headers['Content-Length'], + }, + { + hostname: 'api.elevenlabs.io', + path: '/v1/speech-to-text', + method: 'POST', + apiKey: 'eleven-key', + contentLength: expectedBody.length, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assertNoProviderConcat(concatCalls, 'production ElevenLabs STT path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model_id"', 'name="language_code"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte ElevenLabs multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('Mistral Voxtral STT streams multipart upload parts without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(7 * 1024 * 1024, 0x6d); + const https = createFakeHttps({ responseChunks: [Buffer.from('{"choices":[{"message":{"content":[{"text":"mistral "},"transcript"]}}]}')] }); + const { transcribeAudioWithMistralVoxtral } = await loadMainMediaProviders({ https }); + + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudioWithMistralVoxtral({ + audioBuffer, + apiKey: 'mistral-key', + model: 'voxtral-mini-latest', + language: 'fr', + mimeType: 'audio/mpeg', + })); + + assert.equal(result, 'mistral transcript'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMistralSttParts({ + audioBuffer, + boundary, + language: 'fr', + model: 'voxtral-mini-latest', + mimeType: 'audio/mpeg', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + authorization: req.options.headers.Authorization, + contentLength: req.options.headers['Content-Length'], + timeoutMs: req.timeoutMs, + }, + { + hostname: 'api.mistral.ai', + path: '/v1/audio/transcriptions', + method: 'POST', + authorization: 'Bearer mistral-key', + contentLength: expectedBody.length, + timeoutMs: 60000, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assertNoProviderConcat(concatCalls, 'production Mistral STT path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="language"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte Mistral multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('ElevenLabs TTS streams successful MP3 responses to the temp file without full audio concat', async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-cloud-media-buffering-')); + const audioPath = path.join(tempDir, 'speech.mp3'); + const audioChunks = [ + Buffer.alloc(3 * 1024 * 1024, 0x11), + Buffer.alloc(2 * 1024 * 1024, 0x22), + Buffer.alloc(512 * 1024, 0x33), + ]; + const audioBytes = audioChunks.reduce((total, chunk) => total + chunk.length, 0); + const https = createFakeHttps({ responseChunks: audioChunks }); + const fakeFs = createInstrumentedFs(); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: fakeFs }); + + try { + const { concatCalls } = await instrumentBufferConcat(() => synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath, + timeoutMs: 30000, + })); + + assert.equal(https.requests.length, 1); + const req = https.requests[0]; + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + apiKey: req.options.headers['xi-api-key'], + contentType: req.options.headers['Content-Type'], + accept: req.options.headers.Accept, + timeoutMs: req.timeoutMs, + }, + { + hostname: 'api.elevenlabs.io', + path: '/v1/text-to-speech/voice-id?output_format=mp3_44100_128', + method: 'POST', + apiKey: 'eleven-key', + contentType: 'application/json', + accept: 'audio/mpeg', + timeoutMs: 30000, + }, + ); + assert.deepEqual(JSON.parse(req.writes.join('')), { + text: 'Read this aloud.', + model_id: 'eleven_multilingual_v2', + }); + assert.equal(fs.statSync(audioPath).size, audioBytes); + assert.equal(fakeFs.writeFileCalls.length, 0, 'success path should stream with createWriteStream instead of fs.writeFile'); + assert.equal(fakeFs.createWriteStreamCalls.length, 1); + assertNoProviderConcat(concatCalls, 'production ElevenLabs TTS success path should not call Buffer.concat'); + + t.diagnostic(`before: legacy Buffer.concat would retain a ${audioBytes} byte MP3 response before fs.writeFile`); + t.diagnostic(`after: streamed ${audioBytes} MP3 bytes directly to ${fs.statSync(audioPath).size} temp-file bytes without a full audio Buffer concat`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('ElevenLabs TTS keeps HTTP error text bounded and preserves unusual-activity message', async () => { + { + const https = createFakeHttps({ + statusCode: 500, + responseChunks: [Buffer.from('x'.repeat(2 * 1024 * 1024))], + }); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: createInstrumentedFs() }); + + const { error, concatCalls } = await instrumentBufferConcatRejection(() => synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath: path.join(os.tmpdir(), 'unused-supercmd-tts-error.mp3'), + })); + + assert.match(error.message, /^ElevenLabs TTS HTTP 500: x{500}$/); + assert.equal(error.message.length, 'ElevenLabs TTS HTTP 500: '.length + 500); + assertNoProviderConcat(concatCalls, 'production ElevenLabs TTS error path should not call Buffer.concat'); + } + + { + const https = createFakeHttps({ + statusCode: 401, + responseChunks: [Buffer.from('{"detail":"detected_unusual_activity"}')], + }); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: createInstrumentedFs() }); + + await assert.rejects( + synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath: path.join(os.tmpdir(), 'unused-supercmd-tts-unusual.mp3'), + }), + /ElevenLabs rejected this key due to account restrictions \(detected_unusual_activity\)/, + ); + } +}); + +async function loadMainMediaProviders({ https, fsModule = fs } = {}) { + const cjs = stripMediaProviderTypes(`${providerSource} +module.exports = { + transcribeAudioWithElevenLabs, + transcribeAudioWithMistralVoxtral, + synthesizeElevenLabsToFile, +};`); + const module = { exports: {} }; + const context = vm.createContext({ + Buffer, + Error, + JSON, + Math, + Promise, + String, + console, + module, + exports: module.exports, + require(specifier) { + if (specifier === 'https') return https; + if (specifier === 'fs') return fsModule; + if (specifier === 'stream') return requireFromHere('node:stream'); + if (specifier === 'string_decoder') return requireFromHere('node:string_decoder'); + return requireFromHere(specifier); + }, + }); + + vm.runInContext(cjs, context, { filename: 'main-media-providers.cjs' }); + return module.exports; +} + +function stripMediaProviderTypes(source) { + return source + .replace(/^type BufferedRequestPart = Buffer;\n/m, '') + .replace(/function getBufferedRequestPartsContentLength\(parts: readonly BufferedRequestPart\[\]\): number \{/g, 'function getBufferedRequestPartsContentLength(parts) {') + .replace(/function writeBufferedRequestParts\(req: \{ write: \(chunk: Buffer\) => unknown; end: \(\) => unknown \}, parts: readonly BufferedRequestPart\[\]\): void \{/g, 'function writeBufferedRequestParts(req, parts) {') + .replace(/function collectBoundedResponseText\(res: any, maxBytes = ([^)]+)\): Promise \{/g, 'function collectBoundedResponseText(res, maxBytes = $1) {') + .replace(/function (transcribeAudioWithElevenLabs|transcribeAudioWithMistralVoxtral|synthesizeElevenLabsToFile)\(opts: \{[\s\S]*?\}\): Promise<[^>]+> \{/g, 'function $1(opts) {') + .replace(/ as typeof import\('string_decoder'\)/g, '') + .replace(/ as typeof import\('stream'\)/g, '') + .replace(/: Buffer\[\]/g, '') + .replace(/\((chunk|err|res|part): (?:Buffer \| string|Buffer|Error \| null|any)\) =>/g, '($1) =>') + .replace(/new Promise<[^>]+>/g, 'new Promise'); +} + +async function instrumentBufferConcat(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + stack: new Error().stack || '', + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + const result = await fn(); + return { result, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +async function instrumentBufferConcatRejection(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + stack: new Error().stack || '', + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + await fn(); + assert.fail('expected rejection'); + } catch (error) { + return { error, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +function assertNoProviderConcat(concatCalls, message) { + const providerConcatCalls = concatCalls.filter((call) => call.stack.includes('main-media-providers.cjs')); + assert.equal(providerConcatCalls.length, 0, message); +} + +function createFakeHttps(options = {}) { + const requests = []; + return { + requests, + request(requestOptions, onResponse) { + const req = new FakeClientRequest(requestOptions, onResponse, options); + requests.push(req); + return req; + }, + }; +} + +class FakeClientRequest extends EventEmitter { + constructor(options, onResponse, responseOptions) { + super(); + this.options = options; + this.onResponse = onResponse; + this.responseOptions = responseOptions; + this.destroyed = false; + this.ended = false; + this.timeoutMs = null; + this.writes = []; + } + + write(chunk) { + this.writes.push(chunk); + return true; + } + + end() { + this.ended = true; + queueMicrotask(() => { + if (this.responseOptions.requestError) { + this.emit('error', this.responseOptions.requestError); + return; + } + if (this.destroyed) return; + + const res = new PassThrough(); + res.statusCode = this.responseOptions.statusCode ?? 200; + this.onResponse(res); + for (const chunk of this.responseOptions.responseChunks ?? [Buffer.from('transcript')]) { + res.write(chunk); + } + res.end(); + }); + } + + setTimeout(timeoutMs) { + this.timeoutMs = timeoutMs; + } + + destroy(error) { + this.destroyed = true; + if (error) this.emit('error', error); + } +} + +function createInstrumentedFs() { + return { + ...fs, + createWriteStreamCalls: [], + writeFileCalls: [], + createWriteStream(filePath, options) { + this.createWriteStreamCalls.push({ filePath, options }); + return fs.createWriteStream(filePath, options); + }, + writeFile(...args) { + this.writeFileCalls.push(args); + return fs.writeFile(...args); + }, + }; +} + +function buildExpectedElevenLabsSttParts({ audioBuffer, boundary, language, mimeType, model }) { + const normalized = String(mimeType || '').toLowerCase(); + const filename = normalized.includes('wav') + ? 'audio.wav' + : normalized.includes('mpeg') || normalized.includes('mp3') + ? 'audio.mp3' + : normalized.includes('mp4') || normalized.includes('m4a') + ? 'audio.m4a' + : normalized.includes('ogg') || normalized.includes('oga') + ? 'audio.ogg' + : normalized.includes('flac') + ? 'audio.flac' + : 'audio.webm'; + const contentType = normalized || 'audio/webm'; + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model_id"\r\n\r\n${model}\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language_code"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function buildExpectedMistralSttParts({ audioBuffer, boundary, language, mimeType, model }) { + const normalized = String(mimeType || '').toLowerCase(); + const filename = normalized.includes('mp3') || normalized.includes('mpeg') ? 'audio.mp3' : 'audio.wav'; + const contentType = filename.endsWith('.mp3') ? 'audio/mpeg' : 'audio/wav'; + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model || 'voxtral-mini-latest'}\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function extractBoundary(contentType) { + const match = /^multipart\/form-data; boundary=(.+)$/.exec(contentType); + assert.ok(match, `expected multipart content type, got ${contentType}`); + return match[1]; +} + +function assertMultipartOrder(body, markers) { + const text = body.toString('utf8'); + let previous = -1; + + for (const marker of markers) { + const index = text.indexOf(marker); + assert.notEqual(index, -1, `expected multipart marker ${marker}`); + assert.ok(index > previous, `expected ${marker} after previous multipart field`); + previous = index; + } +} + +function extractSourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.notEqual(start, -1, `expected source marker ${startMarker}`); + assert.notEqual(end, -1, `expected source marker ${endMarker}`); + return source.slice(start, end); +} diff --git a/scripts/test-command-metadata-cache.mjs b/scripts/test-command-metadata-cache.mjs new file mode 100644 index 00000000..fcc64ba0 --- /dev/null +++ b/scripts/test-command-metadata-cache.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import test, { after } 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'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-command-metadata-cache-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath() { return ${JSON.stringify(tempRoot)}; }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return 'all'; } + export function loadSettings() { return globalThis.__superCmdCommandMetadataSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeCommands(count = 250) { + const commands = [ + { + id: 'ext-weather-today', + title: 'Today', + subtitle: 'Weather', + keywords: ['weather', 'today'], + category: 'extension', + path: 'weather/today', + mode: 'view', + }, + { + id: 'script-stock-ticker', + title: 'Stock Ticker', + keywords: ['stock'], + category: 'script', + path: '/tmp/stocks.sh', + mode: 'inline', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Maintenance', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'compact', + }, + ]; + + for (let index = 0; index < count; index += 1) { + commands.push({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + subtitle: `Application ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function findCommand(commands, id) { + const command = commands.find((entry) => entry.id === id); + assert.ok(command, `expected command ${id}`); + return command; +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +function seedFreshCache(commands = makeCommands()) { + commandsModule.__seedCommandCacheForTesting(commands, { cacheTimestamp: Date.now() }); +} + +test('command metadata updates patch cached subtitles without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + seedFreshCache(makeCommands(1_000)); + + const update = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: '72 F and sunny' }); + const afterExtensionUpdate = await commandsModule.getAvailableCommands(); + + assert.deepEqual( + { + matchedCommands: update.matchedCommands, + changedCommands: update.changedCommands, + patchedCachedCommands: update.patchedCachedCommands, + patchedStaleCommandsFallback: update.patchedStaleCommandsFallback, + }, + { + matchedCommands: 1, + changedCommands: 1, + patchedCachedCommands: true, + patchedStaleCommandsFallback: false, + } + ); + assert.equal(findCommand(afterExtensionUpdate, 'ext-weather-today').subtitle, '72 F and sunny'); + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + + const inlineUpdate = commandsModule.applyCommandMetadataUpdate('script-stock-ticker', { subtitle: 'AAPL 210.00' }); + const afterInlineUpdate = await commandsModule.getAvailableCommands(); + assert.equal(inlineUpdate.changedCommands, 1); + assert.equal(findCommand(afterInlineUpdate, 'script-stock-ticker').subtitle, 'AAPL 210.00'); + assert.equal(structuralDiscoveryRuns, 0); + + const ignoredCompactScript = commandsModule.applyCommandMetadataUpdate('script-cleanup', { subtitle: 'Ignored' }); + const afterIgnoredUpdate = await commandsModule.getAvailableCommands(); + assert.equal(ignoredCompactScript.matchedCommands, 0); + assert.equal(findCommand(afterIgnoredUpdate, 'script-cleanup').subtitle, 'Maintenance'); + assert.equal(structuralDiscoveryRuns, 0); + + const removal = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: null }); + const afterRemoval = await commandsModule.getAvailableCommands(); + assert.equal(removal.changedCommands, 1); + assert.equal(findCommand(afterRemoval, 'ext-weather-today').subtitle, 'Weather'); + assert.equal(structuralDiscoveryRuns, 0); +}); + +test('legacy invalidation path starts structural discovery from stale fallback', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + const commands = makeCommands(1_000); + seedFreshCache(commands); + + commandsModule.invalidateCache(); + const returnedCommands = await commandsModule.getAvailableCommands(); + + assert.equal(returnedCommands, commands); + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); +}); + +test('command metadata cache benchmark', async () => { + const commandCount = 5_000; + const iterations = 60; + const targetCommandId = 'script-stock-ticker'; + + commandsModule.__resetCommandCacheForTesting(); + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.getAvailableCommands(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let optimizedStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + optimizedStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const optimizedTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.applyCommandMetadataUpdate(targetCommandId, { subtitle: `Tick ${index}` }); + await commandsModule.getAvailableCommands(); + optimizedTimes.push(performance.now() - startedAt); + } + + const report = { + commandCount, + iterations, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + getCommandsMs: stats(legacyTimes), + }, + optimized: { + structuralDiscoveryStarts: optimizedStructuralDiscoveryRuns, + getCommandsMs: stats(optimizedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - optimizedStructuralDiscoveryRuns, + }; + + console.log('Command metadata cache benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(optimizedStructuralDiscoveryRuns, 0); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/scripts/test-cursor-prompt-stream-batching.mjs b/scripts/test-cursor-prompt-stream-batching.mjs new file mode 100644 index 00000000..b9241522 --- /dev/null +++ b/scripts/test-cursor-prompt-stream-batching.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const CHUNK_COUNT = 500; +const { createCursorPromptResultBatcher } = await importTs( + path.resolve('src/renderer/src/hooks/cursorPromptResultBatcher.ts') +); + +function makeChunks(count) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +function simulateLegacyCursorPromptStream(chunks) { + let visibleResult = ''; + let visibleUpdateCount = 0; + + for (const chunk of chunks) { + visibleResult += chunk; + visibleUpdateCount += 1; + } + + return { visibleResult, visibleUpdateCount }; +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) { + callback(); + } + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createBatcherHarness() { + const scheduler = createManualFrameScheduler(); + const resultRef = { current: '' }; + let visibleResult = ''; + let visibleUpdateCount = 0; + const batcher = createCursorPromptResultBatcher({ + resultRef, + setVisibleResult(nextResult) { + visibleResult = nextResult; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + return { + batcher, + resultRef, + scheduler, + get visibleResult() { + return visibleResult; + }, + get visibleUpdateCount() { + return visibleUpdateCount; + }, + }; +} + +test('baseline cursor prompt streaming updates visible state once per chunk', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const metrics = simulateLegacyCursorPromptStream(chunks); + + assert.equal(metrics.visibleResult, expectedResult); + assert.equal(metrics.visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline visible result updates: ${metrics.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching coalesces many chunks into one visible update per frame', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const harness = createBatcherHarness(); + + for (const chunk of chunks) { + harness.batcher.appendChunk(chunk); + } + + assert.equal(harness.resultRef.current, expectedResult); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); + + assert.equal(harness.scheduler.flushFrames(), 1); + assert.equal(harness.visibleResult, expectedResult); + assert.equal(harness.visibleUpdateCount, 1); + console.log(`batched visible result updates: ${harness.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching flushes the final pending stream synchronously', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('final '); + harness.batcher.appendChunk('answer'); + harness.batcher.flush(); + + assert.equal(harness.resultRef.current, 'final answer'); + assert.equal(harness.visibleResult, 'final answer'); + assert.equal(harness.visibleUpdateCount, 1); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleUpdateCount, 1); +}); + +test('cursor prompt result batching keeps apply text current before the visible frame flush', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk(' rewrite'); + harness.batcher.appendChunk(' this '); + + const textReadByApply = String(harness.resultRef.current || '').trim(); + assert.equal(textReadByApply, 'rewrite this'); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); +}); + +test('cursor prompt result batching can cancel a pending repaint without changing authoritative text', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('partial'); + harness.batcher.cancelPendingFlush(); + + assert.equal(harness.resultRef.current, 'partial'); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); +}); diff --git a/scripts/test-emoji-caret-session.mjs b/scripts/test-emoji-caret-session.mjs new file mode 100644 index 00000000..a531fbeb --- /dev/null +++ b/scripts/test-emoji-caret-session.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +function canRunSwiftTests() { + if (process.platform !== 'darwin') return false; + try { + execFileSync('swiftc', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +if (!canRunSwiftTests()) { + console.log('[emoji-caret-session] skipped: Swift compiler is not available on this platform'); + process.exit(0); +} + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-emoji-caret-session-')); +const testSource = path.join(tempDir, 'main.swift'); +const testBinary = path.join(tempDir, 'emoji-caret-session-tests'); + +fs.writeFileSync(testSource, ` +import Foundation +@preconcurrency import ApplicationServices + +func makeSnapshot(pid: pid_t, bundleId: String = "com.example.Editor") -> AXCaretSessionSnapshot { + AXCaretSessionSnapshot( + context: AXCaretApplicationContext(pid: pid, bundleIdentifier: bundleId), + focusedElement: nil, + caret: AXCaretRect(x: 20, y: 40, w: 1, h: 18, tier: "unit") + ) +} + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("failed: " + message + "\\n", stderr) + exit(1) + } +} + +var cache = EmojiCaretSessionCache() +expect(!cache.isActive, "new cache starts empty") + +cache.store(makeSnapshot(pid: 42)) +expect(cache.isActive, "store activates the cache") +switch cache.validate(eventTargetPID: 42) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "matching event PID reuses the snapshot") +default: + expect(false, "matching event PID reuses the snapshot") +} + +switch cache.validate(eventTargetPID: nil) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "missing event PID does not force an AX requery") +default: + expect(false, "missing event PID does not force an AX requery") +} + +switch cache.validate(eventTargetPID: 43) { +case .invalidated: + break +default: + expect(false, "PID changes invalidate the cache") +} +expect(!cache.isActive, "PID invalidation clears the cache") + +cache.store(makeSnapshot(pid: 99)) +cache.invalidate() +expect(!cache.isActive, "failed rect/dismiss invalidation clears the cache") + +print("emoji caret session cache tests passed") +`); + +try { + execFileSync('swiftc', [ + '-o', testBinary, + 'src/native/ax-caret-query.swift', + 'src/native/emoji-caret-session-cache.swift', + testSource, + '-framework', 'AppKit', + '-framework', 'ApplicationServices', + ], { stdio: 'inherit' }); + const output = execFileSync(testBinary, { encoding: 'utf8' }).trim(); + assert.equal(output, 'emoji caret session cache tests passed'); + console.log(`[emoji-caret-session] ${output}`); +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/test-emoji-grid-virtualization.mjs b/scripts/test-emoji-grid-virtualization.mjs new file mode 100644 index 00000000..a75cc3e5 --- /dev/null +++ b/scripts/test-emoji-grid-virtualization.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function importListHooks() { + const result = await build({ + entryPoints: [path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + }); + const code = result.outputFiles[0].text; + return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64')); +} + +function makeEmojiItems(totalItems) { + return Array.from({ length: totalItems }, (_, index) => ({ + id: `emoji-${index}`, + order: index, + sectionTitle: index < totalItems / 2 ? 'Smileys' : 'Symbols', + props: { + title: `Emoji ${index}`, + icon: '😀', + }, + })); +} + +function countEmojiCells(rows) { + return rows.reduce((count, row) => count + (row.type === 'emoji-row' ? row.items.length : 0), 0); +} + +const hooks = await importListHooks(); + +test('Emoji grid virtualization', async (t) => { + const totalItems = 4096; + const items = makeEmojiItems(totalItems); + const groupedItems = hooks.groupListItems(items); + const gridRows = hooks.buildEmojiGridVirtualRows(groupedItems); + const rowMetrics = hooks.measureVirtualRows(gridRows); + + await t.test('renders only the visible emoji cells plus overscan', () => { + assert.equal(hooks.shouldUseEmojiGrid(items, false, () => true), true, 'fixture should use the emoji grid path'); + assert.equal(countEmojiCells(gridRows), totalItems, 'all items are represented by virtual rows'); + assert.ok(gridRows.length < totalItems, 'grid rows compact many cells into fixed rows'); + + const range = hooks.getVisibleVirtualRange(gridRows, rowMetrics, 0, 600); + const visibleCells = countEmojiCells(gridRows.slice(range.visibleStart, range.visibleEnd)); + + assert.equal(visibleCells, 112); + assert.ok(visibleCells < totalItems * 0.05, 'initial window renders less than 5% of the list'); + console.log(JSON.stringify({ + mode: 'after-test', + totalItems, + virtualRows: gridRows.length, + visibleCells, + visibleStart: range.visibleStart, + visibleEnd: range.visibleEnd, + })); + }); + + await t.test('maps selected indices to their grouped grid rows', () => { + const itemToRow = hooks.buildItemToVirtualRowMap(gridRows, totalItems); + + assert.equal(itemToRow[0], 1, 'first section starts after its header'); + assert.equal(itemToRow[7], 1, 'first row contains eight cells'); + assert.equal(itemToRow[8], 2, 'ninth cell starts the next grid row'); + assert.equal(itemToRow[2047], 256, 'last item in the first section stays in its final row'); + assert.equal(itemToRow[2048], 258, 'second section starts after the next header'); + + const selectedRow = itemToRow[3000]; + const selectedTop = rowMetrics.offsets[selectedRow]; + const selectedRange = hooks.getVisibleVirtualRange(gridRows, rowMetrics, selectedTop, 600); + const visibleCells = countEmojiCells(gridRows.slice(selectedRange.visibleStart, selectedRange.visibleEnd)); + + assert.ok(selectedRow >= selectedRange.visibleStart && selectedRow < selectedRange.visibleEnd, 'selected item row is visible'); + assert.ok(visibleCells < 200, 'selection scroll window stays bounded'); + }); +}); diff --git a/scripts/test-exec-command-timeout-cleanup.mjs b/scripts/test-exec-command-timeout-cleanup.mjs new file mode 100644 index 00000000..4fc98b82 --- /dev/null +++ b/scripts/test-exec-command-timeout-cleanup.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +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 { runExecCommand } = await importTs(path.join(root, 'src/main/exec-command.ts')); + +function createTimerHarness() { + let nextId = 1; + const active = new Map(); + const cleared = []; + + return { + setTimeout(callback, ms) { + const id = nextId++; + active.set(id, { callback, ms }); + return id; + }, + clearTimeout(id) { + cleared.push(id); + active.delete(id); + }, + runNext() { + const next = active.entries().next(); + if (next.done) return false; + const [id, timer] = next.value; + active.delete(id); + timer.callback(); + return true; + }, + get activeCount() { + return active.size; + }, + get cleared() { + return cleared; + }, + }; +} + +function createFakeProc() { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + writes: [], + ended: false, + write(value) { + this.writes.push(value); + }, + end() { + this.ended = true; + }, + }; + proc.killCount = 0; + proc.kill = () => { + proc.killCount += 1; + }; + return proc; +} + +function createHarness() { + const timer = createTimerHarness(); + const spawned = []; + const dependencies = { + spawn(command, args, options) { + const proc = createFakeProc(); + spawned.push({ command, args, options, proc }); + return proc; + }, + existsSync() { + return true; + }, + env: { PATH: '/test/bin' }, + cwd: () => '/test/cwd', + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }; + + return { + timer, + spawned, + run(command = '/bin/test-command', args = ['--flag'], options) { + return runExecCommand(command, args, options, dependencies); + }, + }; +} + +test('exec-command timeout cleanup', async (t) => { + await t.test('clears timeout handle when process closes before timeout', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stdout.emit('data', Buffer.from('done')); + proc.stderr.emit('data', Buffer.from('warn')); + proc.emit('close', 0); + + assert.deepEqual(await promise, { stdout: 'done', stderr: 'warn', exitCode: 0 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); + + await t.test('clears timeout handle when process errors before timeout', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stderr.emit('data', Buffer.from('partial stderr')); + proc.emit('error', new Error('spawn failed')); + + assert.deepEqual(await promise, { stdout: '', stderr: 'spawn failed', exitCode: 1 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); + + await t.test('kills process and resolves with timeout result on timeout path', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stdout.emit('data', Buffer.from('before-timeout')); + assert.equal(harness.timer.runNext(), true); + + assert.deepEqual(await promise, { + stdout: 'before-timeout', + stderr: 'Command timed out', + exitCode: 124, + }); + assert.equal(proc.killCount, 1); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + }); + + await t.test('settles once when close, error, and timeout race', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.emit('close', 7); + proc.emit('error', new Error('late error')); + assert.equal(harness.timer.runNext(), false); + + assert.deepEqual(await promise, { stdout: '', stderr: '', exitCode: 7 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); +}); diff --git a/scripts/test-extension-bundle-cache.mjs b/scripts/test-extension-bundle-cache.mjs new file mode 100644 index 00000000..13679fdb --- /dev/null +++ b/scripts/test-extension-bundle-cache.mjs @@ -0,0 +1,112 @@ +#!/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 { createRequire } from 'node:module'; +import { + bundleExtensionRunner, + createCounters, + createFixture, +} from './measure-extension-bundle-cache.mjs'; + +const require = createRequire(import.meta.url); + +function updatedFixtureManifest() { + return { + name: 'bundle-cache-fixture', + title: 'Bundle Cache Fixture', + description: 'Fixture for extension bundle cache measurement', + owner: 'codex', + commands: [ + { + name: 'background', + title: 'Background Updated', + description: 'No-view command', + mode: 'no-view', + preferences: [ + { + name: 'freshCommandPref', + title: 'Fresh Command Pref', + type: 'textfield', + default: 'default-command', + }, + ], + }, + { + name: 'menu', + title: 'Menu', + description: 'Menu-bar command', + mode: 'menu-bar', + }, + ], + preferences: [ + { + name: 'freshExtensionPref', + title: 'Fresh Extension Pref', + type: 'textfield', + default: 'default-extension', + }, + ], + }; +} + +test('extension bundle cache hits and invalidates by file stats', async () => { + const fixture = createFixture(); + const bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + const { counters, restore } = createCounters(fixture.extDir); + + try { + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-1' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-1' }, + }; + + const first = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(first.title, 'Background'); + assert.match(first.code, /background/); + assert.equal(first.preferences.freshExtensionPref, 'stored-extension-1'); + assert.equal(first.preferences.freshCommandPref, 'stored-command-1'); + assert.equal(counters.packageJsonReads, 1); + assert.equal(counters.bundleReads, 1); + assert.equal(counters.jsonParseCalls, 1); + assert.equal(counters.readdirSync, 1); + + const afterFirst = { ...counters }; + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-2' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-2' }, + }; + + const second = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(second.preferences.freshExtensionPref, 'stored-extension-2'); + assert.equal(second.preferences.freshCommandPref, 'stored-command-2'); + assert.equal(counters.packageJsonReads, afterFirst.packageJsonReads); + assert.equal(counters.bundleReads, afterFirst.bundleReads); + assert.equal(counters.jsonParseCalls, afterFirst.jsonParseCalls); + assert.equal(counters.readdirSync, afterFirst.readdirSync); + + const bundlePath = path.join(fixture.extDir, '.sc-build', 'background.js'); + fs.writeFileSync(bundlePath, 'module.exports = { name: "background", version: 2 };\n'); + const beforeBundleInvalidation = { ...counters }; + const third = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.match(third.code, /version: 2/); + assert.equal(counters.bundleReads, beforeBundleInvalidation.bundleReads + 1); + assert.equal(counters.packageJsonReads, beforeBundleInvalidation.packageJsonReads); + + const manifestPath = path.join(fixture.extDir, 'package.json'); + fs.writeFileSync(manifestPath, JSON.stringify(updatedFixtureManifest(), null, 2)); + const beforeManifestInvalidation = { ...counters }; + const fourth = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(fourth.title, 'Background Updated'); + assert.equal(counters.packageJsonReads, beforeManifestInvalidation.packageJsonReads + 1); + assert.equal(counters.jsonParseCalls, beforeManifestInvalidation.jsonParseCalls + 1); + assert.equal(counters.bundleReads, beforeManifestInvalidation.bundleReads); + } finally { + restore(); + try { fs.unlinkSync(bundledRunner); } catch {} + try { fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); } catch {} + } +}); diff --git a/scripts/test-extension-command-refresh-cache.mjs b/scripts/test-extension-command-refresh-cache.mjs new file mode 100644 index 00000000..96feb009 --- /dev/null +++ b/scripts/test-extension-command-refresh-cache.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +import test, { after } 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'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-extension-command-refresh-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath() { return ${JSON.stringify(tempRoot)}; }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return []; } + export function loadSettings() { return globalThis.__superCmdExtensionRefreshSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeBaseCommands(extraAppCount = 0) { + const commands = [ + { + id: 'app-safari', + title: 'Safari', + keywords: ['browser'], + category: 'app', + path: '/Applications/Safari.app', + }, + { + id: 'settings-network', + title: 'Network', + keywords: ['wifi'], + category: 'settings', + path: 'com.apple.Network-Settings.extension', + }, + { + id: 'ext-old-search', + title: 'Old Search', + subtitle: 'Old Extension', + keywords: ['old'], + category: 'extension', + path: 'old/search', + mode: 'view', + deeplink: 'supercmd://extensions/old/search', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Scripts', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'inline', + }, + { + id: 'quicklink-docs', + title: 'Docs', + subtitle: 'Quick Link', + keywords: ['docs'], + category: 'system', + }, + { + id: 'system-open-settings', + title: 'SuperCmd Settings', + keywords: ['settings'], + category: 'system', + }, + ]; + + for (let index = 0; index < extraAppCount; index += 1) { + commands.unshift({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function makeExtensionCommands(count = 1) { + return Array.from({ length: count }, (_, index) => ({ + id: `ext-new-command-${index}`, + title: `New Command ${index}`, + subtitle: 'New Extension', + keywords: ['new', `command-${index}`], + category: 'extension', + path: `new/command-${index}`, + mode: 'view', + deeplink: `supercmd://extensions/new/command-${index}`, + })); +} + +function commandIds(commands) { + return commands.map((command) => command.id); +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +test('extension command refresh swaps installed extensions without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = { + commandMetadata: { + 'new/command-0': { subtitle: 'Runtime status' }, + }, + }; + + let structuralDiscoveryRuns = 0; + let extensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return makeExtensionCommands(1); + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + assert.equal(extensionDiscoveryRuns, 1); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-new-command-0', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); + assert.equal(refreshed.find((command) => command.id === 'ext-new-command-0')?.subtitle, 'Runtime status'); + assert.equal(refreshed.some((command) => command.id === 'ext-old-search'), false); + assert.equal(await commandsModule.getAvailableCommands(), refreshed); +}); + +test('extension command refresh removes uninstalled extensions from cached command list', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let extensionDiscoveryRuns = 0; + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return []; + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(extensionDiscoveryRuns, 1); + assert.equal(refreshed.some((command) => command.category === 'extension'), false); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); +}); + +test('extension command refresh falls back to full discovery without a command cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); + assert.equal(refreshed.some((command) => command.id === 'app-safari'), true); +}); + +test('extension command refresh benchmark avoids full discovery starts with a warm cache', async () => { + const iterations = 40; + const baseCommandCount = 2_000; + const simulatedStructuralDiscoveryMs = 8; + + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.refreshCommandsNow(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let targetedStructuralDiscoveryRuns = 0; + let targetedExtensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + targetedStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + targetedExtensionDiscoveryRuns += 1; + return makeExtensionCommands(3); + }); + + const targetedTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + await commandsModule.refreshCommandsForExtensionChange(); + targetedTimes.push(performance.now() - startedAt); + } + + const report = { + iterations, + baseCommandCount: makeBaseCommands(baseCommandCount).length, + simulatedStructuralDiscoveryMs, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + refreshMs: stats(legacyTimes), + }, + targeted: { + structuralDiscoveryStarts: targetedStructuralDiscoveryRuns, + extensionDiscoveryStarts: targetedExtensionDiscoveryRuns, + refreshMs: stats(targetedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - targetedStructuralDiscoveryRuns, + }; + + console.log('Extension command refresh benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(targetedStructuralDiscoveryRuns, 0); + assert.equal(targetedExtensionDiscoveryRuns, iterations); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/scripts/test-extension-fetch-proxy-abort.mjs b/scripts/test-extension-fetch-proxy-abort.mjs new file mode 100644 index 00000000..7d7b336c --- /dev/null +++ b/scripts/test-extension-fetch-proxy-abort.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const { installExtensionFetchBridge } = await importTs(path.resolve('src/renderer/src/extension-fetch-bridge.ts')); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +function createTrackedAbortSignal() { + const listeners = new Set(); + return { + signal: { + aborted: false, + addCount: 0, + removeCount: 0, + addEventListener(type, listener) { + if (type !== 'abort') return; + this.addCount += 1; + listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type !== 'abort') return; + this.removeCount += 1; + listeners.delete(listener); + }, + }, + listenerCount: () => listeners.size, + abort() { + this.signal.aborted = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +function createBridgeHarness() { + const pending = []; + const pendingBinary = []; + const requests = []; + const binaryRequests = []; + const canceled = []; + const g = { + crypto: { randomUUID: () => `uuid-${requests.length + 1}` }, + fetch: async () => new Response('native'), + }; + const electron = { + httpRequest: (request) => { + requests.push(request); + const run = deferred(); + pending.push(run); + return run.promise; + }, + cancelHttpRequest: (requestId) => { + canceled.push(requestId); + }, + httpDownloadBinary: (url, options) => { + binaryRequests.push({ url, options }); + const run = deferred(); + pendingBinary.push(run); + return run.promise; + }, + }; + installExtensionFetchBridge(g, () => electron); + return { binaryRequests, canceled, g, pending, pendingBinary, requests }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); +} + +test('extension fetch abort sends IPC cancel and removes its abort listener on completion', async () => { + const { canceled, g, pending, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://api.test/slow', { signal: abort.signal }); + await flushAsync(); + + assert.equal(requests.length, 1); + assert.match(requests[0].requestId, /^extensionFetch:/); + assert.equal(abort.listenerCount(), 1); + assert.equal(abort.signal.addCount, 1); + + abort.abort(); + abort.abort(); + assert.deepEqual(canceled, [requests[0].requestId]); + + pending[0].resolve({ + status: 0, + statusText: 'Request canceled', + headers: {}, + bodyText: '', + url: 'https://api.test/slow', + }); + + await assert.rejects(fetchPromise, { name: 'AbortError' }); + assert.equal(abort.listenerCount(), 0); + assert.equal(abort.signal.removeCount, 1); +}); + +test('extension fetch success path clears abort listener and does not retain request IDs', async () => { + const { canceled, g, pending, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://api.test/items', { signal: abort.signal }); + await flushAsync(); + assert.equal(requests.length, 1); + + pending[0].resolve({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/plain' }, + bodyText: 'done', + url: 'https://api.test/items', + }); + + const response = await fetchPromise; + assert.equal(await response.text(), 'done'); + assert.equal(response.url, 'https://api.test/items'); + assert.deepEqual(canceled, []); + assert.equal(abort.listenerCount(), 0); + assert.equal(abort.signal.removeCount, 1); +}); + +test('extension fetch abort cancels binary download phase and clears listener after rejection', async () => { + const { binaryRequests, canceled, g, pending, pendingBinary, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://cdn.test/image.png', { signal: abort.signal }); + await flushAsync(); + + assert.equal(requests.length, 1); + pending[0].resolve({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'image/png' }, + bodyText: '', + url: 'https://cdn.test/image.png', + }); + await flushAsync(); + + assert.equal(binaryRequests.length, 1); + assert.equal(binaryRequests[0].url, 'https://cdn.test/image.png'); + assert.deepEqual(binaryRequests[0].options, { requestId: requests[0].requestId }); + assert.equal(abort.listenerCount(), 1); + + abort.abort(); + assert.deepEqual(canceled, [requests[0].requestId]); + + const err = new Error('Request canceled'); + err.name = 'AbortError'; + pendingBinary[0].reject(err); + + await assert.rejects(fetchPromise, { name: 'AbortError' }); + assert.equal(abort.listenerCount(), 0); + assert.equal(abort.signal.removeCount, 1); +}); + +test('extension fetch preserves fallback behavior for non-http and unsupported bodies', async () => { + const nativeCalls = []; + const requests = []; + const g = { + fetch: async (input) => { + nativeCalls.push(String(input)); + return new Response('native'); + }, + }; + const electron = { + httpRequest: (request) => { + requests.push(request); + return Promise.resolve({ + status: 200, + statusText: 'OK', + headers: {}, + bodyText: 'proxied', + url: request.url, + }); + }, + }; + installExtensionFetchBridge(g, () => electron); + + assert.equal(await (await g.fetch('data:text/plain,hello')).text(), 'native'); + assert.equal(await (await g.fetch('https://api.test/upload', { method: 'POST', body: new FormData() })).text(), 'native'); + assert.deepEqual(nativeCalls, ['data:text/plain,hello', 'https://api.test/upload']); + assert.deepEqual(requests, []); +}); diff --git a/scripts/test-extension-lifecycle-sandbox.mjs b/scripts/test-extension-lifecycle-sandbox.mjs new file mode 100644 index 00000000..902df664 --- /dev/null +++ b/scripts/test-extension-lifecycle-sandbox.mjs @@ -0,0 +1,586 @@ +#!/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'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const extensionViewPath = path.join(root, 'src/renderer/src/ExtensionView.tsx'); + +function captureFromOptions(options) { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function createFakeEventTarget(label) { + const listenerIds = new WeakMap(); + const listeners = new Map(); + let nextListenerId = 1; + + function listenerId(listener) { + if (!listener || (typeof listener !== 'function' && typeof listener !== 'object')) { + return 'null'; + } + let id = listenerIds.get(listener); + if (!id) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + return id; + } + + function key(type, listener, options) { + return `${String(type)}:${captureFromOptions(options)}:${listenerId(listener)}`; + } + + return { + label, + listeners, + addEventListener(type, listener, options) { + if (!listener) return; + listeners.set(key(type, listener, options), { type, listener, options }); + }, + removeEventListener(type, listener, options) { + listeners.delete(key(type, listener, options)); + }, + }; +} + +function createHostWindow() { + let nextTimerId = 1; + const intervals = new Set(); + const timeouts = new Map(); + const rafs = new Map(); + const windowTarget = createFakeEventTarget('window'); + const documentTarget = createFakeEventTarget('document'); + + const hostWindow = { + document: documentTarget, + navigator: { userAgent: 'SuperCmd lifecycle harness' }, + location: { href: 'supercmd://lifecycle-harness' }, + electron: { marker: 'electron-facade' }, + setInterval() { + const id = nextTimerId++; + intervals.add(id); + return id; + }, + clearInterval(id) { + intervals.delete(id); + }, + setTimeout(handler, _timeout, ...args) { + const id = nextTimerId++; + timeouts.set(id, { handler, args }); + return id; + }, + clearTimeout(id) { + timeouts.delete(id); + }, + requestAnimationFrame(callback) { + const id = nextTimerId++; + rafs.set(id, { callback }); + return id; + }, + cancelAnimationFrame(id) { + rafs.delete(id); + }, + addEventListener: windowTarget.addEventListener, + removeEventListener: windowTarget.removeEventListener, + }; + + hostWindow.window = hostWindow; + hostWindow.self = hostWindow; + hostWindow.globalThis = hostWindow; + hostWindow.global = hostWindow; + hostWindow.top = hostWindow; + hostWindow.parent = hostWindow; + documentTarget.defaultView = hostWindow; + + return { + hostWindow, + hostDocument: documentTarget, + snapshot() { + return { + intervals: intervals.size, + timeouts: timeouts.size, + rafs: rafs.size, + windowListeners: windowTarget.listeners.size, + documentListeners: documentTarget.listeners.size, + total: + intervals.size + + timeouts.size + + rafs.size + + windowTarget.listeners.size + + documentTarget.listeners.size, + }; + }, + reset() { + intervals.clear(); + timeouts.clear(); + rafs.clear(); + windowTarget.listeners.clear(); + documentTarget.listeners.clear(); + }, + flushTimeouts() { + const pending = Array.from(timeouts.entries()); + for (const [id, { handler, args }] of pending) { + if (!timeouts.has(id)) continue; + timeouts.delete(id); + if (typeof handler === 'function') { + handler.call(hostWindow, ...args); + } + } + }, + flushRafs(timestamp = 16.7) { + const pending = Array.from(rafs.entries()); + for (const [id, { callback }] of pending) { + if (!rafs.has(id)) continue; + rafs.delete(id); + callback.call(hostWindow, timestamp); + } + }, + }; +} + +const host = createHostWindow(); +globalThis.window = host.hostWindow; +globalThis.document = host.hostDocument; +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, +}; + +function extractLifecycleSource() { + const source = fs.readFileSync(extensionViewPath, 'utf8'); + const start = source.indexOf('type ExtensionTimerHandle'); + const end = source.indexOf('/**\n * JS shim for the `swift:AppleReminders`', start); + assert.notEqual(start, -1, 'Could not locate lifecycle registry start marker'); + assert.notEqual(end, -1, 'Could not locate lifecycle registry end marker'); + return source + .slice(start, end) + .replace(/\bexport\s+(?=(interface|function)\b)/g, ''); +} + +function loadLifecycleHarness() { + const source = ` + ${extractLifecycleSource()} + globalThis.__extensionLifecycleHarness = { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, + }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.None, + target: ts.ScriptTarget.ES2022, + }, + fileName: extensionViewPath, + }); + const sandbox = { + console, + window: host.hostWindow, + document: host.hostDocument, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: extensionViewPath }); + return sandbox.__extensionLifecycleHarness; +} + +const { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, +} = loadLifecycleHarness(); + +const extensionCode = ` +const onResize = () => {}; +const onVisibilityChange = () => {}; +const intervalId = window.setInterval(() => {}, 60000); +window.addEventListener("resize", onResize); +window.document.addEventListener("visibilitychange", onVisibilityChange); + +exports.default = function LifecycleProbe() { + return { + intervalId, + compatibility: { + documentRead: Boolean(window.document) && window.document === document, + navigatorRead: window.navigator.userAgent, + locationRead: window.location.href, + electronRead: window.electron.marker, + globalWindowRead: globalThis.window === window, + defaultViewRead: document.defaultView === window + } + }; +}; +`; + +const EXTENSION_WRAPPER_ARGUMENTS = [ + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + 'process', + 'Buffer', + 'global', + 'globalThis', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'window', + 'document', + 'navigator', + '__scDynamicImport', +]; + +function createBareTimerApi(registry) { + const setIntervalTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setInterval(callback, timeout, ...args); + registry.intervals.add(id); + registry.intervalClearers.set(id, host.hostWindow.clearInterval); + return id; + }; + const clearIntervalTracked = (id) => { + registry.intervals.delete(id); + registry.intervalClearers.delete(id); + host.hostWindow.clearInterval(id); + }; + const setTimeoutTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setTimeout(callback, timeout, ...args); + registry.timeouts.add(id); + registry.timeoutClearers.set(id, host.hostWindow.clearTimeout); + return id; + }; + const clearTimeoutTracked = (id) => { + registry.timeouts.delete(id); + registry.timeoutClearers.delete(id); + host.hostWindow.clearTimeout(id); + }; + const requestAnimationFrameTracked = (callback) => { + const id = host.hostWindow.requestAnimationFrame(callback); + registry.rafs.add(id); + registry.rafClearers.set(id, host.hostWindow.cancelAnimationFrame); + return id; + }; + const cancelAnimationFrameTracked = (id) => { + registry.rafs.delete(id); + registry.rafClearers.delete(id); + host.hostWindow.cancelAnimationFrame(id); + }; + return { + setImmediate: (callback, ...args) => setTimeoutTracked(() => callback(...args), 0), + clearImmediate: clearTimeoutTracked, + setInterval: setIntervalTracked, + clearInterval: clearIntervalTracked, + setTimeout: setTimeoutTracked, + clearTimeout: clearTimeoutTracked, + requestAnimationFrame: requestAnimationFrameTracked, + cancelAnimationFrame: cancelAnimationFrameTracked, + }; +} + +function runWrapper({ lifecycleScope, registry, scoped }) { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const bareTimerApi = scoped ? lifecycleScope : createBareTimerApi(registry); + const wrapperWindow = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapperDocument = scoped ? lifecycleScope.scopedDocument : host.hostDocument; + const globalObject = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, extensionCode); + + wrapper( + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalObject, + globalObject, + bareTimerApi.setImmediate, + bareTimerApi.clearImmediate, + bareTimerApi.setInterval, + bareTimerApi.clearInterval, + bareTimerApi.setTimeout, + bareTimerApi.clearTimeout, + bareTimerApi.requestAnimationFrame, + bareTimerApi.cancelAnimationFrame, + wrapperWindow, + wrapperDocument, + undefined, + async () => ({}), + ); + + return fakeModule.exports.default(); +} + +test('extension lifecycle sandbox cleanup', async (t) => { + await t.test('documents the pre-fix window API leak', () => { + host.reset(); + const registry = createTimerRegistry(); + const result = runWrapper({ registry, scoped: false }); + const beforeClear = host.snapshot(); + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.equal(registry.intervals.size, 0, 'legacy window.setInterval bypasses the bare timer registry'); + assert.equal(registry.eventListeners.size, 0, 'legacy window.addEventListener bypasses lifecycle cleanup'); + assert.equal(afterClear.total, beforeClear.total, 'legacy registry cleanup leaves window handles behind'); + + console.log('extension lifecycle leak before fix:', { beforeClear, afterClear }); + }); + + await t.test('cleans scoped window timers and listeners on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const result = runWrapper({ lifecycleScope, registry, scoped: true }); + const beforeClear = host.snapshot(); + const registryBeforeClear = { + intervals: registry.intervals.size, + eventListeners: registry.eventListeners.size, + }; + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.deepEqual(registryBeforeClear, { intervals: 1, eventListeners: 2 }); + assert.equal(afterClear.total, 0); + + console.log('extension lifecycle cleanup after fix:', { beforeClear, registryBeforeClear, afterClear }); + }); + + await t.test('prunes fired scoped one-shot timers and rafs', () => { + const oneShotCount = 1000; + + host.reset(); + const staleTimeoutRegistry = createTimerRegistry(); + const staleTimeoutApi = createBareTimerApi(staleTimeoutRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleTimeoutApi.setTimeout(() => {}, 0); + } + host.flushTimeouts(); + const staleTimeoutEntries = staleTimeoutRegistry.timeouts.size; + clearTimerRegistry(staleTimeoutRegistry); + + host.reset(); + const staleRafRegistry = createTimerRegistry(); + const staleRafApi = createBareTimerApi(staleRafRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleRafApi.requestAnimationFrame(() => {}); + } + host.flushRafs(); + const staleRafEntries = staleRafRegistry.rafs.size; + clearTimerRegistry(staleRafRegistry); + + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const intervalId = lifecycleScope.setInterval(() => {}, 60000); + let timeoutCalls = 0; + let timeoutThis = null; + let timeoutArgs = null; + for (let i = 0; i < oneShotCount; i += 1) { + lifecycleScope.setTimeout(function (...args) { + timeoutCalls += 1; + if (timeoutCalls === 1) { + timeoutThis = this; + timeoutArgs = args; + } + }, 0, i, 'timeout-arg'); + } + + assert.equal(registry.intervals.size, 1); + assert.equal(registry.timeouts.size, oneShotCount); + assert.equal(registry.timeoutClearers.size, oneShotCount); + + host.flushTimeouts(); + + assert.equal(timeoutCalls, oneShotCount); + assert.equal(timeoutThis, host.hostWindow); + assert.deepEqual(timeoutArgs, [0, 'timeout-arg']); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.intervals.size, 1, 'interval remains tracked after one-shot timeouts fire'); + assert.equal(registry.intervalClearers.size, 1); + + let rafCalls = 0; + let rafThis = null; + let rafTimestamp = null; + for (let i = 0; i < oneShotCount; i += 1) { + lifecycleScope.requestAnimationFrame(function (timestamp) { + rafCalls += 1; + if (rafCalls === 1) { + rafThis = this; + rafTimestamp = timestamp; + } + }); + } + + assert.equal(registry.rafs.size, oneShotCount); + assert.equal(registry.rafClearers.size, oneShotCount); + + host.flushRafs(123.4); + + assert.equal(rafCalls, oneShotCount); + assert.equal(rafThis, host.hostWindow); + assert.equal(rafTimestamp, 123.4); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + assert.equal(registry.intervals.size, 1, 'interval remains tracked after one-shot rafs fire'); + assert.equal(registry.intervalClearers.size, 1); + + lifecycleScope.clearInterval(intervalId); + assert.equal(registry.intervals.size, 0); + assert.equal(registry.intervalClearers.size, 0); + + console.log(`${oneShotCount} fired timeouts: before stale registry entries=${staleTimeoutEntries}, after=${registry.timeouts.size}`); + console.log(`${oneShotCount} fired RAFs: before stale registry entries=${staleRafEntries}, after=${registry.rafs.size}`); + }); + + await t.test('cleared one-shot handles do not fire or stay registered', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + let timeoutCalls = 0; + let rafCalls = 0; + + const timeoutId = lifecycleScope.setTimeout(() => { + timeoutCalls += 1; + }, 0); + lifecycleScope.clearTimeout(timeoutId); + host.flushTimeouts(); + + const rafId = lifecycleScope.requestAnimationFrame(() => { + rafCalls += 1; + }); + lifecycleScope.cancelAnimationFrame(rafId); + host.flushRafs(); + + assert.equal(timeoutCalls, 0); + assert.equal(rafCalls, 0); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + }); + + await t.test('clears pending scoped one-shots on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + let callbackCalls = 0; + + lifecycleScope.setTimeout(() => { + callbackCalls += 1; + }, 0); + lifecycleScope.requestAnimationFrame(() => { + callbackCalls += 1; + }); + + assert.equal(registry.timeouts.size, 1); + assert.equal(registry.rafs.size, 1); + assert.equal(host.snapshot().timeouts, 1); + assert.equal(host.snapshot().rafs, 1); + + clearTimerRegistry(registry); + host.flushTimeouts(); + host.flushRafs(); + + assert.equal(callbackCalls, 0); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + assert.equal(host.snapshot().timeouts, 0); + assert.equal(host.snapshot().rafs, 0); + }); + + await t.test('prunes one-shot handles when callbacks throw', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + + lifecycleScope.setTimeout(() => { + throw new Error('timeout boom'); + }, 0); + + assert.equal(registry.timeouts.size, 1); + assert.throws(() => host.flushTimeouts(), /timeout boom/); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + + lifecycleScope.requestAnimationFrame(() => { + throw new Error('raf boom'); + }); + + assert.equal(registry.rafs.size, 1); + assert.throws(() => host.flushRafs(), /raf boom/); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + }); + + await t.test('cleans prior scoped handles before re-evaluation', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + + runWrapper({ lifecycleScope, registry, scoped: true }); + const firstEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterReevaluationCleanup = host.snapshot(); + runWrapper({ lifecycleScope, registry, scoped: true }); + const secondEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterUnmount = host.snapshot(); + + assert.equal(firstEvaluation.total, 3); + assert.equal(afterReevaluationCleanup.total, 0); + assert.equal(secondEvaluation.total, 3); + assert.equal(afterUnmount.total, 0); + + console.log('extension lifecycle re-evaluation cleanup:', { + firstEvaluation, + afterReevaluationCleanup, + secondEvaluation, + afterUnmount, + }); + }); +}); diff --git a/scripts/test-extension-runner-multi-command-build.mjs b/scripts/test-extension-runner-multi-command-build.mjs new file mode 100644 index 00000000..e502e9a6 --- /dev/null +++ b/scripts/test-extension-runner-multi-command-build.mjs @@ -0,0 +1,296 @@ +#!/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 { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; +import { + bundleExtensionRunner, +} from './measure-extension-bundle-cache.mjs'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); + +function createBuildFixture(commandCount) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `sc-extension-multi-build-${commandCount}-`)); + const userDataDir = path.join(tmpDir, 'userData'); + const extDir = path.join(tmpDir, 'extension'); + const srcDir = path.join(extDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const commands = Array.from({ length: commandCount }, (_unused, index) => { + const name = `command-${index}`; + fs.writeFileSync( + path.join(srcDir, `${name}.ts`), + [ + `export default async function ${name.replace(/-/g, '_')}() {`, + ` return ${JSON.stringify(name)};`, + `}`, + '', + ].join('\n') + ); + return { + name, + title: `Command ${index}`, + description: `Command ${index}`, + mode: 'view', + }; + }); + + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: `multi-build-fixture-${commandCount}`, + title: `Multi Build Fixture ${commandCount}`, + description: 'Fixture for batched extension builds', + owner: 'codex', + commands, + }, + null, + 2 + ) + ); + + return { tmpDir, userDataDir, extDir }; +} + +async function withBundledRunner(fixture, callback) { + let bundledRunner; + const esbuild = require('esbuild'); + const originalBuild = esbuild.build; + const originalLoad = Module._load; + const buildCalls = []; + const patchedBuild = async function patchedBuild(options) { + if (options?.absWorkingDir === fixture.extDir) { + const entryCount = Array.isArray(options.entryPoints) + ? options.entryPoints.length + : Object.keys(options.entryPoints || {}).length; + buildCalls.push({ + entryCount, + outdir: options.outdir, + outfile: options.outfile, + }); + } + return originalBuild.call(this, options); + }; + Module._load = function patchedModuleLoad(request, parent, isMain) { + if (request === 'esbuild') { + return { ...esbuild, build: patchedBuild }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + return await callback({ runner, buildCalls }); + } finally { + Module._load = originalLoad; + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + } +} + +function readManifest(extDir) { + return JSON.parse(fs.readFileSync(path.join(extDir, 'package.json'), 'utf-8')); +} + +function writeManifest(extDir, pkg) { + fs.writeFileSync(path.join(extDir, 'package.json'), JSON.stringify(pkg, null, 2)); +} + +function mutateManifest(extDir, mutator) { + const pkg = readManifest(extDir); + mutator(pkg); + writeManifest(extDir, pkg); +} + +test('buildAllCommands batches 1/5/20 command fixtures into one esbuild call each', async (t) => { + for (const commandCount of [1, 5, 20]) { + await t.test(`${commandCount} command fixture`, async () => { + const fixture = createBuildFixture(commandCount); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + const built = await runner.buildAllCommands(`multi-build-fixture-${commandCount}`, fixture.extDir); + + assert.equal(built, commandCount); + assert.equal(buildCalls.length, 1, 'expected one esbuild invocation for all commands'); + assert.equal(buildCalls[0].entryCount, commandCount); + assert.equal(buildCalls[0].outdir, path.join(fixture.extDir, '.sc-build')); + assert.equal(buildCalls[0].outfile, undefined); + + for (let index = 0; index < commandCount; index += 1) { + assert.ok( + fs.existsSync(path.join(fixture.extDir, '.sc-build', `command-${index}.js`)), + `expected command-${index}.js output` + ); + } + }); + } finally { + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } + }); + } +}); + +test('buildAllCommands reuses unchanged command bundles', async () => { + const fixture = createBuildFixture(5); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + + assert.equal(buildCalls.length, 1, 'expected unchanged rebuild to skip esbuild'); + assert.ok( + fs.existsSync(path.join(fixture.extDir, '.sc-build', '.sc-build-stamp.json')), + 'expected build stamp' + ); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands rebuilds only stale commands in a multi-command extension', async () => { + const fixture = createBuildFixture(5); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + const untouchedOutFile = path.join(fixture.extDir, '.sc-build', 'command-1.js'); + const untouchedBefore = fs.statSync(untouchedOutFile).mtimeMs; + + fs.appendFileSync(path.join(fixture.extDir, 'src', 'command-0.ts'), '\nexport const changed = true;\n'); + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + + assert.equal(buildCalls.length, 2, 'expected one initial build and one partial rebuild'); + assert.equal(buildCalls[1].entryCount, 1, 'expected only the stale command to be rebuilt'); + assert.equal( + fs.statSync(untouchedOutFile).mtimeMs, + untouchedBefore, + 'expected unchanged command output to be reused' + ); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands rebuilds when source, manifest, tsconfig, externals, or deps change', async (t) => { + const cases = [ + { + name: 'source', + mutate(fixture) { + fs.appendFileSync(path.join(fixture.extDir, 'src', 'command-0.ts'), '\nexport const changed = true;\n'); + }, + }, + { + name: 'manifest command metadata', + mutate(fixture) { + mutateManifest(fixture.extDir, (pkg) => { + pkg.commands[0].title = 'Updated Command Title'; + }); + }, + }, + { + name: 'tsconfig', + mutate(fixture) { + fs.writeFileSync( + path.join(fixture.extDir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }, null, 2) + ); + }, + }, + { + name: 'manifest externals', + mutate(fixture) { + mutateManifest(fixture.extDir, (pkg) => { + pkg.external = ['@example/native-helper']; + }); + }, + }, + { + name: 'dependency manifest', + mutate(fixture) { + fs.mkdirSync(path.join(fixture.extDir, 'node_modules'), { recursive: true }); + mutateManifest(fixture.extDir, (pkg) => { + pkg.dependencies = { 'left-pad': '1.3.0' }; + }); + }, + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const fixture = createBuildFixture(1); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + testCase.mutate(fixture); + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + + assert.equal(buildCalls.length, 2, `expected ${testCase.name} change to rebuild`); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } + }); + } +}); + +test('buildAllCommands keeps pre-built output when source files are missing', async () => { + const fixture = createBuildFixture(1); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + const outFile = path.join(fixture.extDir, '.sc-build', 'command-0.js'); + assert.ok(fs.existsSync(outFile), 'expected initial output'); + + fs.rmSync(path.join(fixture.extDir, 'src'), { recursive: true, force: true }); + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + + assert.ok(fs.existsSync(outFile), 'expected pre-built output to be preserved'); + assert.equal(buildCalls.length, 1, 'expected no rebuild when only pre-built output is available'); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands reports repeated build measurements for 1/5/20 command fixtures', async (t) => { + const enabled = process.env.SUPERCMD_EXTENSION_BUILD_MEASURE === '1'; + if (!enabled) { + t.skip('set SUPERCMD_EXTENSION_BUILD_MEASURE=1 to print local timing evidence'); + return; + } + + for (const commandCount of [1, 5, 20]) { + const fixture = createBuildFixture(commandCount); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + const timingsMs = []; + for (let index = 0; index < 3; index += 1) { + const started = performance.now(); + assert.equal( + await runner.buildAllCommands(`multi-build-fixture-${commandCount}`, fixture.extDir), + commandCount + ); + timingsMs.push(Number((performance.now() - started).toFixed(2))); + } + console.log(JSON.stringify({ + commandCount, + timingsMs, + esbuildCalls: buildCalls.length, + })); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } + } +}); diff --git a/scripts/test-extension-runtime-logging.mjs b/scripts/test-extension-runtime-logging.mjs new file mode 100644 index 00000000..6f5cbcd3 --- /dev/null +++ b/scripts/test-extension-runtime-logging.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const ROOT = process.cwd(); +const EXTENSION_VIEW_PATH = path.join(ROOT, 'src/renderer/src/ExtensionView.tsx'); +const source = fs.readFileSync(EXTENSION_VIEW_PATH, 'utf8'); + +function sourceBetween(startNeedle, endNeedle) { + const start = source.indexOf(startNeedle); + assert.notEqual(start, -1, `Could not find start marker: ${startNeedle}`); + const end = source.indexOf(endNeedle, start); + assert.notEqual(end, -1, `Could not find end marker: ${endNeedle}`); + return source.slice(start, end); +} + +function runTranspiledSnippet(snippet) { + const transpiled = ts.transpileModule(snippet, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: EXTENSION_VIEW_PATH, + }); + const debugCalls = []; + const sandbox = { + console: { + debug: (...args) => debugCalls.push(args), + }, + __debugCalls: debugCalls, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: EXTENSION_VIEW_PATH }); + return sandbox.__result; +} + +test('baseline: legacy success diagnostics log and stringify exported functions', () => { + const logCalls = []; + let toStringCalls = 0; + const exported = function Command() {}; + exported.toString = () => { + toStringCalls += 1; + return 'function Command() { return "ok"; }'; + }; + + const legacyConsole = { + log: (...args) => logCalls.push(args), + }; + + legacyConsole.log('[loadExtensionExport] Extension loaded successfully'); + legacyConsole.log('[loadExtensionExport] Exported type:', typeof exported); + legacyConsole.log('[loadExtensionExport] Exported name:', exported?.name); + legacyConsole.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + + console.log( + `[baseline] extension runtime success diagnostics: logs=${logCalls.length} exportedToStringCalls=${toStringCalls}` + ); + + assert.equal(logCalls.length, 4); + assert.equal(toStringCalls, 1); +}); + +test('extension runtime debug helper is quiet unless explicitly enabled', () => { + const helperSource = sourceBetween( + 'const EXTENSION_RUNTIME_DEBUG_GLOBAL', + '// ─── React Module for Extensions' + ); + const result = runTranspiledSnippet(` + ${helperSource} + const beforeEnabled = isExtensionRuntimeDebugLoggingEnabled(); + logExtensionRuntimeDebug('quiet'); + globalThis[EXTENSION_RUNTIME_DEBUG_GLOBAL] = true; + const afterEnabled = isExtensionRuntimeDebugLoggingEnabled(); + logExtensionRuntimeDebug('loud', 42); + globalThis.__result = { + beforeEnabled, + afterEnabled, + debugCalls: globalThis.__debugCalls, + }; + `); + + console.log( + `[after] extension runtime debug helper: defaultDebugCalls=0 enabledDebugCalls=${result.debugCalls.length}` + ); + + assert.equal(result.beforeEnabled, false); + assert.equal(result.afterEnabled, true); + assert.deepEqual(result.debugCalls, [['loud', 42]]); +}); + +test('success-path extension runtime diagnostics are gated behind debug logging', () => { + const moduleReactSetup = sourceBetween( + '// Create React module for extensions', + '// ─── JSX Runtime for Extensions' + ); + const reactRequireBridge = sourceBetween( + 'let reactRequireCount = 0;', + '// ── Raycast API shim' + ); + const loadSuccessPath = sourceBetween( + 'const exported =\n fakeModule.exports.default || fakeModule.exports;', + 'if (typeof exported === \'function\')' + ); + const viewRenderer = sourceBetween( + 'const ViewRenderer: React.FC<{', + 'const ScopedExtensionContext: React.FC<{' + ); + + for (const [label, snippet] of [ + ['module React setup', moduleReactSetup], + ['fakeRequire React bridge', reactRequireBridge], + ['loadExtensionExport success path', loadSuccessPath], + ['ViewRenderer render path', viewRenderer], + ]) { + assert.doesNotMatch(snippet, /console\.log/, `${label} must not emit unconditional console.log diagnostics`); + } + + assert.match(moduleReactSetup, /logExtensionRuntimeDebug/); + assert.match(reactRequireBridge, /logExtensionRuntimeDebug/); + assert.match(loadSuccessPath, /if \(isExtensionRuntimeDebugLoggingEnabled\(\)\)/); + assert.match(viewRenderer, /logExtensionRuntimeDebug/); + + const beforeDebugGate = loadSuccessPath.slice( + 0, + loadSuccessPath.indexOf('if (isExtensionRuntimeDebugLoggingEnabled())') + ); + assert.doesNotMatch(beforeDebugGate, /toString/, 'exported function toString must not run before debug is enabled'); + assert.match(loadSuccessPath, /console\.debug\('\[loadExtensionExport\] Exported function:', exported\?\.toString/); +}); + +test('actionable warnings and errors remain available', () => { + assert.match(source, /console\.warn\('Extension exported an object, not a function\. Trying to wrap it\.'\)/); + assert.match(source, /console\.warn\(`Extension tried to require unknown module:/); + assert.match(source, /console\.error\('Extension did not export a function\. Got:'/); + assert.match(source, /console\.error\('Failed to load extension:'/); + assert.match(source, /console\.error\('Stack:'/); +}); diff --git a/scripts/test-extension-sync-facade.mjs b/scripts/test-extension-sync-facade.mjs new file mode 100644 index 00000000..e1ed0132 --- /dev/null +++ b/scripts/test-extension-sync-facade.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const ROOT = process.cwd(); +const EXTENSION_VIEW_PATH = path.join(ROOT, 'src/renderer/src/ExtensionView.tsx'); +const SYNC_METHODS = ['execCommandSync', 'fileExistsSync', 'readFileSync', 'statSync']; + +function parseArgs() { + const args = new Set(process.argv.slice(2)); + const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) || '--mode=both'; + const mode = modeArg.slice('--mode='.length); + if (!['fallback', 'node', 'both'].includes(mode)) { + throw new Error(`Unsupported --mode value: ${mode}`); + } + return { + mode, + assertNodeDirect: !args.has('--report-only') || args.has('--assert-node-direct'), + }; +} + +function createLocalStorage() { + const store = new Map(); + return { + get length() { + return store.size; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + getItem(key) { + return store.has(String(key)) ? store.get(String(key)) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + }, + removeItem(key) { + store.delete(String(key)); + }, + clear() { + store.clear(); + }, + }; +} + +function createSyncIpcProbe() { + const entries = []; + const counts = Object.fromEntries(SYNC_METHODS.map((name) => [name, 0])); + const timeMs = Object.fromEntries(SYNC_METHODS.map((name) => [name, 0])); + + function record(name, fn) { + const started = performance.now(); + try { + return fn(); + } finally { + const elapsed = performance.now() - started; + counts[name] += 1; + timeMs[name] += elapsed; + entries.push({ name, elapsedMs: elapsed }); + } + } + + function snapshot() { + return { + counts: { ...counts }, + timeMs: { ...timeMs }, + totalCount: Object.values(counts).reduce((sum, value) => sum + value, 0), + totalTimeMs: Object.values(timeMs).reduce((sum, value) => sum + value, 0), + }; + } + + function delta(before) { + const after = snapshot(); + const deltaCounts = {}; + const deltaTimeMs = {}; + for (const name of SYNC_METHODS) { + deltaCounts[name] = after.counts[name] - before.counts[name]; + deltaTimeMs[name] = after.timeMs[name] - before.timeMs[name]; + } + return { + counts: deltaCounts, + timeMs: deltaTimeMs, + totalCount: Object.values(deltaCounts).reduce((sum, value) => sum + value, 0), + totalTimeMs: Object.values(deltaTimeMs).reduce((sum, value) => sum + value, 0), + }; + } + + return { entries, record, snapshot, delta }; +} + +function statPayload(filePath) { + const stat = fs.statSync(filePath); + return { + exists: true, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + size: stat.size, + mode: stat.mode, + uid: stat.uid, + gid: stat.gid, + dev: stat.dev, + ino: stat.ino, + nlink: stat.nlink, + atimeMs: stat.atimeMs, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + birthtimeMs: stat.birthtimeMs, + }; +} + +function createElectronFacade(probe) { + return { + execCommandSync(command, args = [], options = {}) { + return probe.record('execCommandSync', () => { + if (command === '/bin/ls' && args[0] === '-A1') { + return { + stdout: fs.readdirSync(args[1]).join('\n'), + stderr: '', + exitCode: 0, + }; + } + const childProcess = require('child_process'); + const result = childProcess.spawnSync(command, args, { + shell: options.shell ?? false, + env: { ...process.env, ...options.env }, + cwd: options.cwd || process.cwd(), + input: options.input, + encoding: 'utf8', + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: typeof result.status === 'number' ? result.status : result.error ? 1 : 0, + }; + }); + }, + fileExistsSync(filePath) { + return probe.record('fileExistsSync', () => fs.existsSync(filePath)); + }, + readFileSync(filePath) { + return probe.record('readFileSync', () => { + try { + return { data: fs.readFileSync(filePath, 'utf8'), error: null }; + } catch (error) { + return { data: null, error: error instanceof Error ? error.message : String(error) }; + } + }); + }, + statSync(filePath) { + return probe.record('statSync', () => { + try { + return statPayload(filePath); + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } + }); + }, + }; +} + +function extractFsFacadeSource() { + const source = fs.readFileSync(EXTENSION_VIEW_PATH, 'utf8'); + const start = source.indexOf('const _bufferMarker'); + const end = source.indexOf('// ── path stub', start); + assert.notEqual(start, -1, 'Could not locate Buffer/fs facade start marker'); + assert.notEqual(end, -1, 'Could not locate fs facade end marker'); + return source.slice(start, end); +} + +function loadFsFacade({ nodeAvailable, electron, localStorage }) { + const source = ` + const USE_REAL_NODE_BUILTINS = ${nodeAvailable ? 'true' : 'false'}; + const _realNodeRequire = ${nodeAvailable ? '__REAL_NODE_REQUIRE' : 'undefined'}; + const _realNodeProcess = ${nodeAvailable ? '__REAL_NODE_PROCESS' : 'undefined'}; + const noop = () => {}; + const noopAsync = (..._args) => Promise.resolve(); + const noopCb = (...args) => { + const cb = args[args.length - 1]; + if (typeof cb === 'function') cb(null); + }; + ${extractFsFacadeSource()} + globalThis.__extensionSyncFacadeHarness = { fsStub, commandPathCache }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: EXTENSION_VIEW_PATH, + }); + const sandbox = { + __REAL_NODE_REQUIRE: nodeAvailable ? require : undefined, + __REAL_NODE_PROCESS: nodeAvailable ? process : undefined, + console, + localStorage, + window: { + electron, + __scNodeRequire: nodeAvailable ? require : undefined, + }, + performance, + TextEncoder, + TextDecoder, + URL, + Uint8Array, + ArrayBuffer, + DataView, + Blob: globalThis.Blob, + File: globalThis.File, + ReadableStream: globalThis.ReadableStream, + WritableStream: globalThis.WritableStream, + TransformStream: globalThis.TransformStream, + atob: (value) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value) => Buffer.from(value, 'binary').toString('base64'), + setTimeout, + clearTimeout, + queueMicrotask, + Promise, + Date, + Error, + Map, + Set, + Symbol, + Object, + Array, + String, + Number, + Boolean, + RegExp, + Math, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: EXTENSION_VIEW_PATH }); + return sandbox.__extensionSyncFacadeHarness.fsStub; +} + +function writeFixtureTree() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-extension-sync-facade-')); + const nestedDir = path.join(dir, 'nested'); + fs.mkdirSync(nestedDir); + fs.writeFileSync(path.join(dir, 'real.txt'), 'real text', 'utf8'); + fs.writeFileSync(path.join(dir, 'shadow.txt'), 'real shadow', 'utf8'); + fs.writeFileSync(path.join(nestedDir, 'child.txt'), 'child text', 'utf8'); + return dir; +} + +function runScenario(mode) { + const probe = createSyncIpcProbe(); + const localStorage = createLocalStorage(); + const electron = createElectronFacade(probe); + const tmpDir = writeFixtureTree(); + const fsStub = loadFsFacade({ + nodeAvailable: mode === 'node', + electron, + localStorage, + }); + + try { + const realFile = path.join(tmpDir, 'real.txt'); + const shadowFile = path.join(tmpDir, 'shadow.txt'); + const virtualFile = path.join(tmpDir, 'virtual.txt'); + + fsStub.writeFileSync(shadowFile, 'virtual shadow'); + fsStub.writeFileSync(virtualFile, 'virtual only'); + + const beforeVirtual = probe.snapshot(); + assert.equal(fsStub.readFileSync(shadowFile, 'utf8'), 'virtual shadow'); + assert.equal(fsStub.existsSync(shadowFile), true); + assert.equal(fsStub.statSync(shadowFile).size, 'virtual shadow'.length); + const virtualDelta = probe.delta(beforeVirtual); + assert.equal(virtualDelta.totalCount, 0, 'virtual/localStorage operations must not hit sync IPC'); + + const beforeReal = probe.snapshot(); + assert.equal(fsStub.existsSync(realFile), true); + assert.equal(fsStub.statSync(realFile).isFile(), true); + assert.equal(fsStub.readFileSync(realFile, 'utf8'), 'real text'); + assert.doesNotThrow(() => fsStub.accessSync(realFile)); + const entries = fsStub.readdirSync(tmpDir, { withFileTypes: true }); + assert.ok(entries.some((entry) => String(entry.name) === 'real.txt' && entry.isFile())); + assert.ok(entries.some((entry) => String(entry.name) === 'nested' && entry.isDirectory())); + assert.ok(entries.some((entry) => String(entry.name) === 'virtual.txt' && entry.isFile())); + const realDelta = probe.delta(beforeReal); + + return { + mode, + realPathOps: realDelta, + virtualPathOps: virtualDelta, + total: probe.snapshot(), + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function formatCounts(result) { + return SYNC_METHODS + .map((name) => `${name}=${result.realPathOps.counts[name]}`) + .join(', '); +} + +const { mode, assertNodeDirect } = parseArgs(); +const modes = mode === 'both' ? ['fallback', 'node'] : [mode]; +const results = modes.map((entry) => runScenario(entry)); + +for (const result of results) { + const elapsed = result.realPathOps.totalTimeMs.toFixed(3); + console.log( + `[extension-sync-facade:${result.mode}] real path ops sync IPC calls: ${result.realPathOps.totalCount} (${formatCounts(result)}), sync IPC time: ${elapsed}ms` + ); + console.log( + `[extension-sync-facade:${result.mode}] virtual precedence sync IPC calls: ${result.virtualPathOps.totalCount}` + ); +} + +const fallbackResult = results.find((result) => result.mode === 'fallback'); +if (fallbackResult) { + assert.ok( + fallbackResult.realPathOps.totalCount > 0, + 'fallback mode should exercise the sync IPC path for real filesystem operations' + ); +} + +const nodeResult = results.find((result) => result.mode === 'node'); +if (assertNodeDirect && nodeResult) { + assert.equal( + nodeResult.realPathOps.totalCount, + 0, + 'node mode should avoid sync IPC for representative real filesystem operations' + ); +} diff --git a/scripts/test-extension-wrapper-cache.mjs b/scripts/test-extension-wrapper-cache.mjs new file mode 100644 index 00000000..861213b3 --- /dev/null +++ b/scripts/test-extension-wrapper-cache.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +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 { + clearCompiledExtensionWrapperCache, + getCompiledExtensionWrapper, + getCompiledExtensionWrapperCacheStats, +} = await importTs(path.join(root, 'src/renderer/src/utils/extension-wrapper-cache.ts')); + +function createRunEnv() { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const timers = new Set(); + const trackTimeout = (cb, ms, ...rest) => { + const id = setTimeout(cb, ms, ...rest); + timers.add(id); + return id; + }; + const trackClearTimeout = (id) => { + timers.delete(id); + clearTimeout(id); + }; + + return { + fakeModule, + timers, + args: [ + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalThis, + globalThis, + (fn, ...args) => trackTimeout(() => fn(...args), 0), + trackClearTimeout, + () => 0, + () => {}, + trackTimeout, + trackClearTimeout, + () => 0, + () => {}, + globalThis, + globalThis.document || {}, + undefined, + async () => ({}), + ], + cleanup: () => { + timers.forEach((id) => clearTimeout(id)); + timers.clear(); + }, + }; +} + +function runWrapper(wrapper) { + const env = createRunEnv(); + wrapper(...env.args); + return env; +} + +test('Extension wrapper cache', async (t) => { + await t.test('reuses the compiled wrapper for the same extension code', () => { + clearCompiledExtensionWrapperCache(); + const code = 'exports.default = function Command() { return "ok"; };'; + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + const second = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + + assert.equal(first.cacheHit, false); + assert.equal(second.cacheHit, true); + assert.strictEqual(second.wrapper, first.wrapper); + assert.equal(getCompiledExtensionWrapperCacheStats().wrapperCreationCount, 1); + }); + + await t.test('invalidates when code changes even if the length is unchanged', () => { + clearCompiledExtensionWrapperCache(); + const codeA = 'exports.default = function Command() { return "a"; };'; + const codeB = 'exports.default = function Command() { return "b"; };'; + assert.equal(codeA.length, codeB.length, 'fixture code must keep equal length'); + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code: codeA, + }); + const changed = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code: codeB, + }); + + assert.equal(first.cacheHit, false); + assert.equal(changed.cacheHit, false); + assert.notEqual(changed.cacheKey, first.cacheKey); + assert.notStrictEqual(changed.wrapper, first.wrapper); + + const envA = runWrapper(first.wrapper); + const envB = runWrapper(changed.wrapper); + try { + assert.equal(envA.fakeModule.exports.default(), 'a'); + assert.equal(envB.fakeModule.exports.default(), 'b'); + } finally { + envA.cleanup(); + envB.cleanup(); + } + }); + + await t.test('keeps module exports and timer handles fresh across cached runs', () => { + clearCompiledExtensionWrapperCache(); + const code = ` +let moduleScopedCounter = 0; +moduleScopedCounter += 1; +const timerId = setTimeout(() => {}, 60000); +exports.default = { + moduleScopedCounter, + timerId, + previousTouchedValue: exports.touched +}; +exports.touched = "mutated"; +`; + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + const second = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + assert.equal(second.cacheHit, true); + assert.strictEqual(second.wrapper, first.wrapper); + + const envA = runWrapper(second.wrapper); + const envB = runWrapper(second.wrapper); + try { + assert.notStrictEqual(envA.fakeModule.exports, envB.fakeModule.exports); + assert.equal(envA.fakeModule.exports.default.moduleScopedCounter, 1); + assert.equal(envB.fakeModule.exports.default.moduleScopedCounter, 1); + assert.equal(envA.fakeModule.exports.default.previousTouchedValue, undefined); + assert.equal(envB.fakeModule.exports.default.previousTouchedValue, undefined); + assert.equal(envA.timers.size, 1); + assert.equal(envB.timers.size, 1); + assert.notStrictEqual( + envA.fakeModule.exports.default.timerId, + envB.fakeModule.exports.default.timerId + ); + } finally { + envA.cleanup(); + envB.cleanup(); + } + + assert.equal(envA.timers.size, 0); + assert.equal(envB.timers.size, 0); + }); +}); diff --git a/scripts/test-file-search-delete-batch.mjs b/scripts/test-file-search-delete-batch.mjs new file mode 100644 index 00000000..a72fc95e --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + collapseNestedDeletedPaths, + indexEntry, + searchIndexedFiles, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, + setActiveIndex(snapshot, homeDir) { + activeIndex = snapshot; + configuredHomeDir = homeDir; + includeRoots = [homeDir]; + includeProtectedHomeRoots = true; + lastBuildStartedAt = 0; + lastIndexError = null; + }, +}; +`; + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +const api = loadFileSearchIndexInternals(); +const homeDir = '/tmp/supercmd-file-search-delete-tests'; + +function addEntry(snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function entryForPath(snapshot, filePath) { + const id = snapshot.pathToEntryId.get(filePath); + assert.notEqual(id, undefined, `${filePath} should be indexed`); + return snapshot.entries[id]; +} + +function isDeleted(snapshot, filePath) { + return Boolean(entryForPath(snapshot, filePath).deleted); +} + +test('file delete tombstones only the exact indexed file', () => { + const snapshot = api.makeSnapshot(); + const deletedFile = path.join(homeDir, 'notes', 'alpha.txt'); + const prefixSibling = path.join(homeDir, 'notes', 'alpha.txt.backup'); + const survivor = path.join(homeDir, 'notes', 'beta.txt'); + + addEntry(snapshot, path.dirname(deletedFile), true); + addEntry(snapshot, deletedFile); + addEntry(snapshot, prefixSibling); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedFile]); + + assert.equal(isDeleted(snapshot, deletedFile), true); + assert.equal(isDeleted(snapshot, prefixSibling), false); + assert.equal(isDeleted(snapshot, survivor), false); + + api.indexEntry(snapshot, { + path: deletedFile, + name: path.basename(deletedFile), + parentPath: path.dirname(deletedFile), + isDirectory: false, + }); + assert.equal(isDeleted(snapshot, deletedFile), false); +}); + +test('batched indexed file deletes do not scan unrelated entries', () => { + const snapshot = api.makeSnapshot(); + const deletedFiles = []; + const bulkDir = path.join(homeDir, 'bulk-files'); + + addEntry(snapshot, bulkDir, true); + for (let index = 0; index < 64; index += 1) { + const filePath = path.join(bulkDir, `deleted-${index}.txt`); + deletedFiles.push(filePath); + addEntry(snapshot, filePath); + } + + snapshot.entries.push({ + get path() { + throw new Error('direct indexed file deletes should not scan unrelated entry paths'); + }, + name: 'sentinel.txt', + parentPath: bulkDir, + normalizedName: 'sentinel txt', + normalizedPath: 'sentinel', + normalizedTildePath: 'sentinel', + compactName: 'sentineltxt', + tokens: ['sentinel', 'txt'], + pathTokens: ['sentinel'], + isDirectory: false, + }); + + api.tombstoneDeletedPaths(snapshot, deletedFiles); + + for (const filePath of deletedFiles) { + assert.equal(isDeleted(snapshot, filePath), true); + } +}); + +test('directory delete tombstones the directory and all indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedDir = path.join(homeDir, 'project'); + const childDir = path.join(deletedDir, 'src'); + const childFile = path.join(childDir, 'index.ts'); + const survivor = path.join(homeDir, 'other', 'index.ts'); + + addEntry(snapshot, deletedDir, true); + addEntry(snapshot, childDir, true); + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(survivor), true); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedDir]); + + assert.equal(isDeleted(snapshot, deletedDir), true); + assert.equal(isDeleted(snapshot, childDir), true); + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, survivor), false); +}); + +test('unknown deleted paths still tombstone indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'unknown-root'); + const childFile = path.join(deletedRoot, 'child.txt'); + const siblingFile = path.join(homeDir, 'unknown-root-copy', 'child.txt'); + + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(siblingFile), true); + addEntry(snapshot, siblingFile); + + api.tombstoneDeletedPaths(snapshot, [deletedRoot]); + + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, siblingFile), false); +}); + +test('nested directory deletes collapse to the outermost deleted root', () => { + const deletedRoot = path.join(homeDir, 'nested-root'); + const nestedDir = path.join(deletedRoot, 'child'); + const nestedFile = path.join(nestedDir, 'deep.txt'); + + assert.deepEqual( + Array.from(api.collapseNestedDeletedPaths([nestedFile, nestedDir, deletedRoot])), + [deletedRoot] + ); + + const snapshot = api.makeSnapshot(); + const similarSibling = path.join(homeDir, 'nested-root-copy', 'deep.txt'); + addEntry(snapshot, deletedRoot, true); + addEntry(snapshot, nestedDir, true); + addEntry(snapshot, nestedFile); + addEntry(snapshot, path.dirname(similarSibling), true); + addEntry(snapshot, similarSibling); + + api.tombstoneDeletedPaths(snapshot, [nestedFile, nestedDir, deletedRoot]); + + assert.equal(isDeleted(snapshot, deletedRoot), true); + assert.equal(isDeleted(snapshot, nestedDir), true); + assert.equal(isDeleted(snapshot, nestedFile), true); + assert.equal(isDeleted(snapshot, similarSibling), false); +}); + +test('non-matching delete paths do not tombstone prefix-like neighbors', async () => { + const snapshot = api.makeSnapshot(); + const prefixLikeDelete = path.join(homeDir, 'docs'); + const survivorDir = path.join(homeDir, 'docs-old'); + const survivor = path.join(survivorDir, 'report.txt'); + const searchable = path.join(homeDir, 'searchable', 'needle-survivor.txt'); + + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + addEntry(snapshot, path.dirname(searchable), true); + addEntry(snapshot, searchable); + + api.tombstoneDeletedPaths(snapshot, [ + prefixLikeDelete, + path.join(homeDir, 'missing', 'child'), + ]); + + assert.equal(isDeleted(snapshot, survivor), false); + assert.equal(isDeleted(snapshot, searchable), false); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('needle-survivor', { limit: 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, searchable); +}); diff --git a/scripts/test-file-search-index.mjs b/scripts/test-file-search-index.mjs new file mode 100644 index 00000000..03dee3a3 --- /dev/null +++ b/scripts/test-file-search-index.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import vm from 'node:vm'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const SOURCE_PATH = path.resolve('src/main/file-search-index.ts'); +const BASELINE_MODE = process.env.SUPERCMD_FILE_INDEX_BASELINE === '1'; +const HEALTHY_INTERVAL_TICKS = 3; +const CLOCK_STEP_MS = 60_000; +const moduleCache = new Map(); + +function createFakeDate(clock) { + return class FakeDate extends Date { + constructor(...args) { + super(...(args.length > 0 ? args : [clock.now])); + } + + static now() { + return clock.now; + } + }; +} + +function transpileSource(source, fileName) { + return ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName, + }).outputText; +} + +function loadFileSearchIndexModule({ clock, logs }) { + const source = `${fs.readFileSync(SOURCE_PATH, 'utf8')} + +export const __fileSearchIndexTestInternals = { + configureForTest(homeDir: string) { + configuredHomeDir = path.resolve(homeDir); + includeRoots = resolveIncludeRoots(configuredHomeDir); + includeProtectedHomeRoots = false; + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + lastBuildStartedAt = 0; + }, + async applyWatchEventBatchForTest(paths: string[]) { + await applyWatchEventBatch(paths); + }, + setWatcherAvailableForTest(available: boolean) { + activeWatcher = available ? ({ close() {} } as fs.FSWatcher) : null; + watchedHomeDir = available ? configuredHomeDir : ''; + if (available && typeof lastWatcherError !== 'undefined') lastWatcherError = null; + }, + async waitForIdleForTest() { + while (rebuildPromise || indexing) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }, +}; +`; + + const module = { exports: {} }; + const localRequire = (request) => require(request); + const quietConsole = { + ...console, + log: (...args) => { + logs.push(args.map(String).join(' ')); + }, + warn: (...args) => { + logs.push(args.map(String).join(' ')); + }, + error: (...args) => { + logs.push(args.map(String).join(' ')); + }, + }; + const sandboxProcess = Object.create(process); + Object.defineProperty(sandboxProcess, 'platform', { value: 'linux' }); + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console: quietConsole, + URL, + Date: createFakeDate(clock), + Math, + String, + Number, + Boolean, + Set, + Map, + WeakMap, + Object, + Array, + RegExp, + Promise, + process: sandboxProcess, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + + vm.runInNewContext(transpileSource(source, SOURCE_PATH), sandbox, { filename: SOURCE_PATH }); + return module.exports; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Boolean, + Set, + Map, + WeakMap, + Object, + Array, + RegExp, + Promise, + process, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + vm.runInNewContext(transpileSource(source, resolvedPath), sandbox, { filename: resolvedPath }); + return module.exports; +} + +async function createFixtureTree(rootDir) { + const projectRoot = path.join(rootDir, 'Projects'); + await fs.promises.mkdir(projectRoot, { recursive: true }); + + for (let dirIndex = 0; dirIndex < 40; dirIndex += 1) { + const dir = path.join(projectRoot, `bucket-${String(dirIndex).padStart(2, '0')}`); + await fs.promises.mkdir(dir, { recursive: true }); + const writes = []; + for (let fileIndex = 0; fileIndex < 25; fileIndex += 1) { + writes.push( + fs.promises.writeFile( + path.join(dir, `alpha-file-${String(dirIndex).padStart(2, '0')}-${String(fileIndex).padStart(2, '0')}.txt`), + `fixture ${dirIndex} ${fileIndex}\n` + ) + ); + } + await Promise.all(writes); + } +} + +function rebuildLogCount(logs) { + return logs.filter((line) => line.includes('[FileIndex] Rebuilt')).length; +} + +async function measure(label, fn) { + const startedAt = performance.now(); + const value = await fn(); + return { + label, + value, + ms: Number((performance.now() - startedAt).toFixed(2)), + }; +} + +async function runRequestedRefresh(indexModule, reason, logs) { + const before = rebuildLogCount(logs); + const measurement = await measure(reason, async () => { + indexModule.requestFileSearchIndexRefresh(reason); + await indexModule.__fileSearchIndexTestInternals.waitForIdleForTest(); + }); + return { + rebuilds: rebuildLogCount(logs) - before, + ms: measurement.ms, + }; +} + +async function runWatcherScenario() { + const tempParent = await fs.promises.realpath(os.tmpdir()); + const tempHome = await fs.promises.mkdtemp(path.join(tempParent, 'supercmd-file-index-')); + const clock = { now: 1_800_000_000_000 }; + const logs = []; + const indexModule = loadFileSearchIndexModule({ clock, logs }); + const internals = indexModule.__fileSearchIndexTestInternals; + + try { + await createFixtureTree(tempHome); + internals.configureForTest(tempHome); + + const startupBefore = rebuildLogCount(logs); + const startup = await measure('startup', () => indexModule.rebuildFileSearchIndex('startup')); + const startupRebuilds = rebuildLogCount(logs) - startupBefore; + assert.equal(startupRebuilds, 1, 'startup should perform one full rebuild'); + + internals.setWatcherAvailableForTest(true); + + internals.setWatcherAvailableForTest(false); + const watcherErrorRecovery = await runRequestedRefresh(indexModule, 'watcher-error', logs); + internals.setWatcherAvailableForTest(true); + + const existingFile = path.join(tempHome, 'Projects', 'bucket-00', 'alpha-file-00-00.txt'); + await fs.promises.writeFile(existingFile, 'updated fixture\n'); + const updateBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([existingFile]); + const updatedResults = await indexModule.searchIndexedFiles('alpha file 00 00', { limit: 5 }); + assert.ok(updatedResults.some((result) => result.path === existingFile), 'updated file should remain searchable'); + const updateRebuilds = rebuildLogCount(logs) - updateBefore; + + const createdFile = path.join(tempHome, 'Projects', 'bucket-00', 'new-alpha-special.txt'); + await fs.promises.writeFile(createdFile, 'new fixture\n'); + const createBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([createdFile]); + const createdResults = await indexModule.searchIndexedFiles('new alpha special', { limit: 5 }); + assert.ok(createdResults.some((result) => result.path === createdFile), 'created file should be searchable'); + const createRebuilds = rebuildLogCount(logs) - createBefore; + + await fs.promises.unlink(createdFile); + const deleteBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([createdFile]); + const deletedResults = await indexModule.searchIndexedFiles('new alpha special', { limit: 5 }); + assert.equal(deletedResults.some((result) => result.path === createdFile), false, 'deleted file should be tombstoned'); + const deleteRebuilds = rebuildLogCount(logs) - deleteBefore; + + const addedDir = path.join(tempHome, 'Projects', 'fresh-folder'); + const nestedFile = path.join(addedDir, 'nested-special-note.md'); + await fs.promises.mkdir(addedDir, { recursive: true }); + await fs.promises.writeFile(nestedFile, 'nested fixture\n'); + const directoryBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([addedDir]); + const nestedResults = await indexModule.searchIndexedFiles('nested special note', { limit: 5 }); + assert.ok(nestedResults.some((result) => result.path === nestedFile), 'new directory contents should be indexed'); + const directoryRebuilds = rebuildLogCount(logs) - directoryBefore; + + const healthyIntervalRuns = []; + for (let tick = 0; tick < HEALTHY_INTERVAL_TICKS; tick += 1) { + clock.now += CLOCK_STEP_MS; + healthyIntervalRuns.push(await runRequestedRefresh(indexModule, 'interval', logs)); + } + + internals.setWatcherAvailableForTest(false); + clock.now += CLOCK_STEP_MS; + const fallbackInterval = await runRequestedRefresh(indexModule, 'interval', logs); + + return { + startupMs: startup.ms, + startupRebuilds, + watcherErrorRecoveryRebuilds: watcherErrorRecovery.rebuilds, + watcherErrorRecoveryMs: watcherErrorRecovery.ms, + watcherBatchRebuilds: updateRebuilds + createRebuilds + deleteRebuilds + directoryRebuilds, + healthyIntervalRebuilds: healthyIntervalRuns.reduce((sum, run) => sum + run.rebuilds, 0), + healthyIntervalMs: Number(healthyIntervalRuns.reduce((sum, run) => sum + run.ms, 0).toFixed(2)), + fallbackIntervalRebuilds: fallbackInterval.rebuilds, + fallbackIntervalMs: fallbackInterval.ms, + indexedEntryCount: indexModule.getFileSearchIndexStatus().indexedEntryCount, + }; + } finally { + indexModule.stopFileSearchIndexing(); + await fs.promises.rm(tempHome, { recursive: true, force: true }); + } +} + +function writeFile(filePath, contents = '') { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +function makeTempHome(label) { + const tmpRoot = fs.realpathSync(os.tmpdir()); + return fs.mkdtempSync(path.join(tmpRoot, `supercmd-file-search-${label}-`)); +} + +function removeTempHome(homeDir) { + try { + fs.rmSync(homeDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup for temp fixtures. + } +} + +async function withIndexedHome(homeDir, fn) { + moduleCache.delete(SOURCE_PATH); + const fileSearch = loadTsModule(SOURCE_PATH); + fileSearch.startFileSearchIndexing({ + homeDir, + refreshIntervalMs: 30_000, + includeProtectedHomeRoots: true, + }); + await fileSearch.rebuildFileSearchIndex('test'); + try { + await fn(fileSearch); + } finally { + fileSearch.stopFileSearchIndexing(); + } +} + +function resultPaths(results) { + return results.map((result) => result.path); +} + +function populateSyntheticTree(homeDir, targetEntries) { + const projectsDir = path.join(homeDir, 'Projects'); + const filesPerApp = 48; + const appCount = Math.max(1, Math.ceil(targetEntries / (filesPerApp + 2))); + + for (let appIndex = 0; appIndex < appCount; appIndex += 1) { + const appName = `app-${String(appIndex).padStart(4, '0')}`; + const srcDir = path.join(projectsDir, appName, 'src'); + const docsDir = path.join(projectsDir, appName, 'docs'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(docsDir, { recursive: true }); + for (let fileIndex = 0; fileIndex < filesPerApp; fileIndex += 1) { + const bucket = fileIndex % 2 === 0 ? srcDir : docsDir; + const name = `module-${String(fileIndex).padStart(3, '0')}-${appName}.ts`; + fs.writeFileSync(path.join(bucket, name), ''); + } + } + + return { + appCount, + filesPerApp, + indexedEntryEstimate: appCount * (filesPerApp + 3) + 1, + }; +} + +async function runPathQueryPerformanceHarness() { + const targetEntries = Number(process.env.FILE_SEARCH_PERF_ENTRIES || 60000); + const iterations = Number(process.env.FILE_SEARCH_PERF_ITERATIONS || 24); + const homeDir = makeTempHome('perf'); + try { + const fixture = populateSyntheticTree(homeDir, targetEntries); + await withIndexedHome(homeDir, async ({ getFileSearchIndexStatus, searchIndexedFiles }) => { + const status = getFileSearchIndexStatus(); + const queryAppIds = [7, 42, 137, Math.floor(fixture.appCount / 2), fixture.appCount - 3] + .filter((value, index, values) => value >= 0 && value < fixture.appCount && values.indexOf(value) === index) + .map((value) => String(value).padStart(4, '0')); + const queries = queryAppIds.flatMap((id) => [ + path.join(homeDir, 'Projects', `app-${id}`, 'src'), + `~/Projects/app-${id}/src`, + `Projects/app-${id}/src`, + ]); + + const startedAt = performance.now(); + let totalResults = 0; + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const query of queries) { + const results = await searchIndexedFiles(query, { limit: 1 }); + totalResults += results.length; + } + } + const elapsedMs = performance.now() - startedAt; + const queryCount = iterations * queries.length; + const metric = { + entries: status.indexedEntryCount, + estimatedEntries: fixture.indexedEntryEstimate, + queries: queryCount, + elapsedMs: Number(elapsedMs.toFixed(2)), + avgMsPerQuery: Number((elapsedMs / queryCount).toFixed(3)), + totalResults, + }; + console.log(`FILE_SEARCH_PERF ${JSON.stringify(metric)}`); + }); + } finally { + removeTempHome(homeDir); + } +} + +test('file search index uses watcher batches and avoids healthy interval rebuilds', async () => { + const metrics = await runWatcherScenario(); + console.log(`[FileIndexTest] ${JSON.stringify({ mode: BASELINE_MODE ? 'baseline' : 'assert', ...metrics })}`); + + assert.equal(metrics.watcherBatchRebuilds, 0, 'watcher-style updates should not trigger full rebuilds'); + assert.equal(metrics.watcherErrorRecoveryRebuilds, 1, 'watcher-error recovery should bypass the rebuild throttle'); + assert.equal(metrics.fallbackIntervalRebuilds, 1, 'interval should rebuild when watcher state is unavailable'); + + if (!BASELINE_MODE) { + assert.equal(metrics.healthyIntervalRebuilds, 0, 'healthy watcher interval ticks should use incremental state'); + } +}); + +test('path-like queries match absolute, tilde, and relative paths', async () => { + const homeDir = makeTempHome('correctness'); + try { + const srcDir = path.join(homeDir, 'Projects', 'app-042', 'src'); + const exactFile = path.join(srcDir, 'Report Final.txt'); + writeFile(exactFile, 'report'); + writeFile(path.join(srcDir, 'Report Notes.md'), 'notes'); + writeFile(path.join(homeDir, 'Projects', 'app-042', 'README.md'), 'readme'); + writeFile(path.join(homeDir, 'Archive', 'Reports', 'src', 'Q2 Plan.txt'), 'plan'); + + await withIndexedHome(homeDir, async ({ searchIndexedFiles }) => { + const absolute = await searchIndexedFiles(srcDir, { limit: 8 }); + assert.equal(absolute[0]?.path, srcDir); + assert.ok(resultPaths(absolute).includes(exactFile)); + + const tilde = await searchIndexedFiles('~/Projects/app-042/src', { limit: 8 }); + assert.equal(tilde[0]?.path, srcDir); + assert.ok(resultPaths(tilde).includes(exactFile)); + + const relative = await searchIndexedFiles('Projects/app-042/src', { limit: 8 }); + assert.equal(relative[0]?.path, srcDir); + assert.ok(resultPaths(relative).includes(exactFile)); + + const exact = await searchIndexedFiles('~/Projects/app-042/src/Report Final.txt', { limit: 4 }); + assert.equal(exact[0]?.path, exactFile); + assert.equal(exact[0]?.matchKind, 'path'); + }); + } finally { + removeTempHome(homeDir); + } +}); + +test('path-like fallback preserves mid-token slash matches', async () => { + const homeDir = makeTempHome('fallback'); + try { + const fallbackFile = path.join(homeDir, 'Archive', 'Reports', 'src', 'Q2 Plan.txt'); + writeFile(fallbackFile, 'plan'); + writeFile(path.join(homeDir, 'Archive', 'Exports', 'src', 'Other.txt'), 'other'); + + await withIndexedHome(homeDir, async ({ searchIndexedFiles }) => { + const midToken = await searchIndexedFiles('ports/src', { limit: 8 }); + assert.ok(resultPaths(midToken).includes(fallbackFile)); + + const trailingSlash = await searchIndexedFiles('ports/', { limit: 8 }); + assert.ok(resultPaths(trailingSlash).includes(fallbackFile)); + + const noMatch = await searchIndexedFiles('~/Archive/Missing/src', { limit: 8 }); + assert.equal(noMatch.length, 0); + }); + } finally { + removeTempHome(homeDir); + } +}); + +if (process.env.SUPERCMD_FILE_SEARCH_PERF === '1') { + test('path-like file search performance harness', async () => { + await runPathQueryPerformanceHarness(); + }); +} diff --git a/scripts/test-file-search-perf-harness.mjs b/scripts/test-file-search-perf-harness.mjs new file mode 100644 index 00000000..e0f01085 --- /dev/null +++ b/scripts/test-file-search-perf-harness.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { runFileSearchPerfHarness } from './file-search-perf-harness.mjs'; + +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; +const IS_PERF_REPORT = IS_PERF_CI || process.env.SUPERCMD_PERF_REPORT === '1'; + +function budgetEntry(actual, budget) { + return { + actualMs: actual, + budgetMs: budget, + budgetUsedPct: budget > 0 ? Number(((actual / budget) * 100).toFixed(1)) : null, + }; +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function pickBudgetEvidence(summary) { + const thresholds = summary.thresholds.applied; + return { + label: summary.label, + config: summary.config, + fixture: { + indexedEntries: summary.fixture.initialEntryCount, + indexedFiles: summary.fixture.initialFileCount, + updateCount: summary.fixture.updateCount, + deleteCount: summary.fixture.deleteCount, + }, + budgets: { + initialIndexMs: budgetEntry(summary.metrics.initialIndexMs, thresholds.initialIndexMs), + normalQueryP95Ms: budgetEntry(summary.metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms), + pathQueryP95Ms: budgetEntry(summary.metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms), + eventLoopLagP95Ms: budgetEntry(summary.metrics.eventLoopLag.p95Ms, thresholds.eventLoopLagP95Ms), + watchUpdateBatchMs: budgetEntry(summary.metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs), + deleteBatchMs: budgetEntry(summary.metrics.deleteBatchMs, thresholds.deleteBatchMs), + }, + metrics: { + initialIndexMs: summary.metrics.initialIndexMs, + initialIndexEventLoopDelayP95Ms: summary.metrics.initialIndexEventLoopDelay.p95Ms, + normalQueryP95Ms: summary.metrics.normalQueries.p95Ms, + pathQueryP95Ms: summary.metrics.pathLikeQueries.p95Ms, + eventLoopLagP95Ms: summary.metrics.eventLoopLag.p95Ms, + eventLoopLagMaxMs: summary.metrics.eventLoopLag.maxMs, + watchUpdateBatchMs: summary.metrics.watchUpdateBatchMs, + deleteBatchMs: summary.metrics.deleteBatchMs, + postUpdateQueryMs: summary.metrics.postUpdateQueryMs, + postDeleteQueryMs: summary.metrics.postDeleteQueryMs, + }, + thresholds: summary.thresholds, + thresholdStatus: summary.thresholds.passed ? 'passed' : 'failed', + cleanup: summary.cleanup, + }; +} + +function printFileSearchPerfReport(label, summary) { + if (!IS_PERF_REPORT) return; + console.log(JSON.stringify({ fileSearchPerf: pickBudgetEvidence({ ...summary, label }) }, null, 2)); +} + +test('file search perf harness covers deterministic temp-home scenarios', async () => { + const summary = await runFileSearchPerfHarness({ + projects: 8, + modulesPerProject: 3, + filesPerModule: 6, + queryRuns: 2, + updateCount: 10, + deleteCount: 10, + limit: 8, + thresholds: { + initialIndexMs: 15_000, + normalQueryP95Ms: 2_000, + pathQueryP95Ms: 2_000, + eventLoopLagP95Ms: 2_000, + watchUpdateBatchMs: 5_000, + deleteBatchMs: 5_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + printFileSearchPerfReport('deterministic-small', summary); + assert.equal(summary.verification.homeDirectoryUsed, summary.fixture.homeDir); + assert.equal(summary.verification.homeIsTempDirectory, true); + assert.ok( + summary.fixture.homeDir.startsWith(await fs.realpath(os.tmpdir())), + 'harness must use an OS temp home' + ); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'temp fixture should be cleaned up'); + + assert.ok(summary.fixture.initialEntryCount >= summary.fixture.initialFileCount); + assert.ok(summary.metrics.initialIndexMs >= 0); + assert.ok(summary.metrics.normalQueries.totalResults > 0); + assert.ok(summary.metrics.pathLikeQueries.totalResults > 0); + assert.ok(summary.metrics.eventLoopLag.p95Ms >= 0); + assert.ok(summary.verification.postUpdateResultCount > 0); + assert.equal(summary.verification.deletedExactMatches, 0); +}); + +test('file search perf CI covers large path-query p95 and event-loop lag budgets', { skip: !IS_PERF_CI }, async () => { + const summary = await runFileSearchPerfHarness({ + projects: 120, + modulesPerProject: 8, + filesPerModule: 32, + queryRuns: 3, + updateCount: 64, + deleteCount: 64, + limit: 16, + thresholds: { + initialIndexMs: 60_000, + normalQueryP95Ms: 3_000, + pathQueryP95Ms: 3_000, + eventLoopLagP95Ms: 5_000, + watchUpdateBatchMs: 10_000, + deleteBatchMs: 10_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + printFileSearchPerfReport('ci-large', summary); + assert.ok( + summary.fixture.initialEntryCount >= 30_000 && summary.fixture.initialEntryCount <= 60_000, + `expected 30k-60k indexed entries, got ${summary.fixture.initialEntryCount}` + ); + assert.ok(summary.metrics.pathLikeQueries.p95Ms <= summary.thresholds.applied.pathQueryP95Ms); + assert.ok(summary.metrics.eventLoopLag.p95Ms <= summary.thresholds.applied.eventLoopLagP95Ms); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'large temp fixture should be cleaned up'); +}); diff --git a/scripts/test-form-runtime-error-state-noop.mjs b/scripts/test-form-runtime-error-state-noop.mjs new file mode 100644 index 00000000..13a387ca --- /dev/null +++ b/scripts/test-form-runtime-error-state-noop.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const FORM_RUNTIME_PATH = 'src/renderer/src/raycast-api/form-runtime.tsx'; +const FORM_RUNTIME_STATE_PATH = 'src/renderer/src/raycast-api/form-runtime-state.ts'; +const FIELD_CHANGE_COUNTS = [10, 100, 1000]; + +function importStateHelpers() { + const source = fs.readFileSync(FORM_RUNTIME_STATE_PATH, 'utf8'); + const executableSource = source + .replace(/export type FormErrorMap = Record;\n\n/, '') + .replace(/export function /g, 'function ') + .replace(/: FormErrorMap/g, '') + .replace(/: string/g, ''); + + const context = {}; + vm.runInNewContext(`${executableSource}\nthis.clearFormFieldError = clearFormFieldError;\nthis.setFormFieldError = setFormFieldError;`, context); + return { + clearFormFieldError: context.clearFormFieldError, + setFormFieldError: context.setFormFieldError, + }; +} + +function analyzeFormRuntimeSource() { + const source = fs.readFileSync(FORM_RUNTIME_PATH, 'utf8'); + return { + usesClearHelper: source.includes('const next = clearFormFieldError(previous, id);'), + usesSetHelper: source.includes('const next = setFormFieldError(previous, id, error);'), + skipsPublishWhenUnchanged: source.includes('if (next === previous) return previous;'), + }; +} + +function measureNoErrorFieldChanges(clearFormFieldError, fieldChanges) { + let errors = {}; + let currentSnapshot = errors; + let errorIdentityChanges = 0; + + for (let index = 0; index < fieldChanges; index += 1) { + const next = clearFormFieldError(errors, `field-${index}`); + if (next !== currentSnapshot) { + errorIdentityChanges += 1; + } + currentSnapshot = next; + errors = next; + } + + return { fieldChanges, errorIdentityChanges }; +} + +function getMetrics() { + const { clearFormFieldError, setFormFieldError } = importStateHelpers(); + return { + noExistingErrors: FIELD_CHANGE_COUNTS.map((count) => measureNoErrorFieldChanges(clearFormFieldError, count)), + clearExistingErrorPreservesOthers: (() => { + const previous = { first: 'Required', second: 'Invalid' }; + const next = clearFormFieldError(previous, 'first'); + return { + changedIdentity: next !== previous, + clearedFieldMissing: !Object.prototype.hasOwnProperty.call(next, 'first'), + preservedOtherField: next.second === 'Invalid', + }; + })(), + setSameError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Required'); + return { changedIdentity: next !== previous }; + })(), + setNewError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Invalid'); + return { changedIdentity: next !== previous, value: next.first }; + })(), + }; +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ source: analyzeFormRuntimeSource(), metrics: getMetrics() }, null, 2)); +} else { + test('Form setValue skips error state updates when the field has no error', () => { + const source = analyzeFormRuntimeSource(); + assert.equal(source.usesClearHelper, true, 'Form setValue should use the guarded error clear helper'); + assert.equal(source.skipsPublishWhenUnchanged, true, 'unchanged errors should not republish the global error snapshot'); + + for (const metric of getMetrics().noExistingErrors) { + assert.equal(metric.errorIdentityChanges, 0, `${metric.fieldChanges} no-error changes should keep the same errors object`); + } + }); + + test('Form setValue still clears an existing field error only', () => { + const metric = getMetrics().clearExistingErrorPreservesOthers; + assert.equal(metric.changedIdentity, true, 'clearing an existing error should publish a new errors object'); + assert.equal(metric.clearedFieldMissing, true, 'the changed field error should be removed'); + assert.equal(metric.preservedOtherField, true, 'unrelated field errors should be preserved'); + }); + + test('Form setError skips same-value updates but publishes changed errors', () => { + const source = analyzeFormRuntimeSource(); + assert.equal(source.usesSetHelper, true, 'Form setError should use the guarded error set helper'); + + const metrics = getMetrics(); + assert.equal(metrics.setSameError.changedIdentity, false, 'setting the same error should keep the previous errors object'); + assert.equal(metrics.setNewError.changedIdentity, true, 'setting a different error should publish a new errors object'); + assert.equal(metrics.setNewError.value, 'Invalid'); + }); +} diff --git a/scripts/test-form-runtime-snapshot-cleanup.mjs b/scripts/test-form-runtime-snapshot-cleanup.mjs new file mode 100644 index 00000000..0df9fed7 --- /dev/null +++ b/scripts/test-form-runtime-snapshot-cleanup.mjs @@ -0,0 +1,303 @@ +#!/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'; +import { TextEncoder } from 'node:util'; + +const require = createRequire(import.meta.url); +const FORM_RUNTIME_PATH = 'src/renderer/src/raycast-api/form-runtime.tsx'; +const FORM_RUNTIME_CONTEXT_PATH = 'src/renderer/src/raycast-api/form-runtime-context.tsx'; + +let esbuild; +try { + esbuild = require('esbuild'); +} catch { + esbuild = null; +} + +function createFakeReact() { + let activeRuntime = null; + + const areDepsEqual = (left, right) => { + if (!left || !right || left.length !== right.length) return false; + return left.every((value, index) => Object.is(value, right[index])); + }; + + const react = { + createContext(defaultValue) { + const context = { _currentValue: defaultValue, _lastProviderValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + context._lastProviderValue = props.value; + return props.children ?? null; + }; + return context; + }, + + useContext(context) { + return context._currentValue; + }, + + useState(initialValue) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + if (!(index in runtime.hooks)) { + runtime.hooks[index] = typeof initialValue === 'function' ? initialValue() : initialValue; + } + const setState = (nextValue) => { + const previous = runtime.hooks[index]; + runtime.hooks[index] = typeof nextValue === 'function' ? nextValue(previous) : nextValue; + }; + return [runtime.hooks[index], setState]; + }, + + useRef(initialValue) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + if (!(index in runtime.hooks)) { + runtime.hooks[index] = { current: initialValue }; + } + return runtime.hooks[index]; + }, + + useMemo(factory, deps) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + const previous = runtime.hooks[index]; + if (previous && areDepsEqual(previous.deps, deps)) return previous.value; + const value = factory(); + runtime.hooks[index] = { deps, value }; + return value; + }, + + useCallback(callback, deps) { + return react.useMemo(() => callback, deps); + }, + + useEffect(effect, deps) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + const previous = runtime.effects[index]; + if (previous && areDepsEqual(previous.deps, deps)) return; + runtime.pendingEffects.push({ index, deps, effect, previousCleanup: previous?.cleanup }); + }, + + createElement(type, props, ...children) { + const nextProps = { + ...(props || {}), + children: children.length <= 1 ? children[0] : children, + }; + if (typeof type === 'function') { + return type(nextProps); + } + return { type, props: nextProps }; + }, + }; + + react.Fragment = Symbol.for('react.fragment'); + + react.createRenderer = (Component, props) => { + const runtime = { + hooks: [], + effects: [], + hookIndex: 0, + pendingEffects: [], + output: null, + + render(nextProps = props) { + props = nextProps; + runtime.hookIndex = 0; + runtime.pendingEffects = []; + activeRuntime = runtime; + try { + runtime.output = Component(props); + } finally { + activeRuntime = null; + } + for (const pending of runtime.pendingEffects) { + pending.previousCleanup?.(); + runtime.effects[pending.index] = { + deps: pending.deps, + cleanup: pending.effect() || undefined, + }; + } + return runtime.output; + }, + + unmount() { + for (const effect of runtime.effects) { + effect?.cleanup?.(); + } + runtime.effects = []; + }, + }; + return runtime; + }; + + return react; +} + +function compileModule(modulePath, stubs) { + const source = fs.readFileSync(modulePath, 'utf8'); + const loader = path.extname(modulePath) === '.tsx' ? 'tsx' : 'ts'; + const { code } = esbuild.transformSync(source, { + format: 'cjs', + jsx: 'transform', + loader, + target: 'es2020', + }); + const module = { exports: {} }; + const localRequire = (id) => { + if (Object.prototype.hasOwnProperty.call(stubs, id)) return stubs[id]; + throw new Error(`Unexpected require(${id}) while compiling ${modulePath}`); + }; + + vm.runInNewContext(code, { + console, + exports: module.exports, + module, + require: localRequire, + Symbol, + TextEncoder, + window: { + addEventListener() {}, + removeEventListener() {}, + }, + }); + return module.exports; +} + +function createRuntimeHarness() { + const fakeReact = createFakeReact(); + const formRuntimeState = { + clearFormFieldError(previous, id) { + if (!Object.prototype.hasOwnProperty.call(previous, id)) return previous; + const next = { ...previous }; + delete next[id]; + return next; + }, + setFormFieldError(previous, id, error) { + if (previous[id] === error) return previous; + return { ...previous, [id]: error }; + }, + }; + const contextExports = compileModule(FORM_RUNTIME_CONTEXT_PATH, { react: fakeReact }); + const runtimeExports = compileModule(FORM_RUNTIME_PATH, { + react: fakeReact, + './form-runtime-context': contextExports, + './form-runtime-fields': { attachFormFields() {} }, + './form-runtime-state': formRuntimeState, + '../i18n': { useI18n: () => ({ t: (key) => key }) }, + }); + + const { Form } = runtimeExports.createFormRuntime({ + ExtensionInfoReactContext: fakeReact.createContext({}), + useNavigation: () => ({ pop() {} }), + useCollectedActions: () => ({ collectedActions: [], registryAPI: {} }), + ActionRegistryContext: fakeReact.createContext({}), + ActionPanelOverlay: () => null, + matchesShortcut: () => false, + isMetaK: () => false, + renderShortcut: () => null, + getExtensionContext: () => ({ extensionName: 'Snapshot Harness' }), + }); + + const renderer = fakeReact.createRenderer(Form, { + children: null, + actions: null, + navigationTitle: 'Snapshot Harness', + isLoading: false, + draftValues: {}, + }); + + return { contextExports, renderer }; +} + +function updateMountedForm(contextExports) { + const formContext = contextExports.FormContext._lastProviderValue; + formContext.setValue('submitted', 'value'.repeat(128)); + formContext.setValue('placeholder-backed', ''); + formContext.setPlaceholder('placeholder-backed', 'fallback'.repeat(96)); + formContext.setError('submitted', 'Required'.repeat(32)); +} + +function exerciseSnapshotLifecycle({ runUnmountCleanup }) { + const { contextExports, renderer } = createRuntimeHarness(); + renderer.render(); + const afterMount = contextExports.getFormSnapshotRetentionMetrics(); + + updateMountedForm(contextExports); + renderer.render(); + const beforeUnmount = contextExports.getFormSnapshotRetentionMetrics(); + const submittedValues = contextExports.getFormValues(); + const submittedErrors = contextExports.getFormErrors(); + + if (runUnmountCleanup) { + renderer.unmount(); + } + + return { + afterMount, + beforeUnmount, + afterUnmount: contextExports.getFormSnapshotRetentionMetrics(), + submittedValues, + submittedErrors, + }; +} + +if (process.argv.includes('--report')) { + if (!esbuild) { + console.log(JSON.stringify({ skipped: 'esbuild dependency is not installed' }, null, 2)); + } else { + console.log( + JSON.stringify( + { + previousBehavior: exerciseSnapshotLifecycle({ runUnmountCleanup: false }), + fixedBehavior: exerciseSnapshotLifecycle({ runUnmountCleanup: true }), + }, + null, + 2, + ), + ); + } +} else if (!esbuild) { + test('Form runtime unmount clears retained global snapshots', { skip: 'esbuild dependency is not installed' }, () => {}); +} else { + test('Form runtime unmount clears retained global snapshots', () => { + const previousBehavior = exerciseSnapshotLifecycle({ runUnmountCleanup: false }); + assert.equal(previousBehavior.beforeUnmount.totalKeys > 0, true, 'mounted form should publish global snapshot keys'); + assert.equal( + previousBehavior.afterUnmount.totalKeys, + previousBehavior.beforeUnmount.totalKeys, + 'without an unmount cleanup, form snapshot keys remain retained', + ); + assert.equal( + previousBehavior.afterUnmount.serializedBytes, + previousBehavior.beforeUnmount.serializedBytes, + 'without an unmount cleanup, serialized snapshot bytes remain retained', + ); + + const fixedBehavior = exerciseSnapshotLifecycle({ runUnmountCleanup: true }); + assert.equal( + fixedBehavior.submittedValues['placeholder-backed'], + 'fallback'.repeat(96), + 'placeholder fallback should remain available while the form is mounted', + ); + assert.equal(fixedBehavior.submittedErrors.submitted, 'Required'.repeat(32)); + assert.equal(fixedBehavior.beforeUnmount.totalKeys, previousBehavior.beforeUnmount.totalKeys); + assert.equal(fixedBehavior.beforeUnmount.serializedBytes, previousBehavior.beforeUnmount.serializedBytes); + assert.equal(fixedBehavior.afterUnmount.valueKeys, 0); + assert.equal(fixedBehavior.afterUnmount.errorKeys, 0); + assert.equal(fixedBehavior.afterUnmount.placeholderKeys, 0); + assert.equal(fixedBehavior.afterUnmount.totalKeys, 0); + assert.equal(fixedBehavior.afterUnmount.serializedBytes, 43); + }); +} diff --git a/scripts/test-grid-virtualization.mjs b/scripts/test-grid-virtualization.mjs new file mode 100644 index 00000000..5740e850 --- /dev/null +++ b/scripts/test-grid-virtualization.mjs @@ -0,0 +1,259 @@ +#!/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 { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const helperPath = path.join(root, 'src/renderer/src/raycast-api/grid-runtime-virtualization.ts'); + +const ITEM_COUNT = 5000; +const VIEWPORT_HEIGHT = 640; +const CONTAINER_WIDTH = 800; +const DEFAULT_COLUMNS = 5; + +function makeLargeGridGroups() { + const firstItems = []; + const secondItems = []; + + for (let index = 0; index < ITEM_COUNT; index += 1) { + const item = { + item: { + id: `item-${index}`, + order: index, + props: { + id: `visible-${index}`, + title: `Item ${index}`, + subtitle: `Subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: index % 2 === 0 ? 'blue' : undefined } + : { value: `Icon.Circle.${index}`, tooltip: `Icon ${index}` }, + }, + section: index < ITEM_COUNT / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + }, + globalIdx: index, + }; + + if (index < ITEM_COUNT / 2) firstItems.push(item); + else secondItems.push(item); + } + + return [ + { key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: firstItems }, + { + key: 'section-b', + title: 'Section B', + section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + items: secondItems, + }, + ]; +} + +function countEagerRenderedCells(groups) { + return groups.reduce((total, group) => total + group.items.length, 0); +} + +function simulateCellContentResolution(groups) { + let checksum = 0; + for (const group of groups) { + for (const { item } of group.items) { + const content = item.props.content; + if (typeof content?.source === 'string') checksum += content.source.length; + if (typeof content?.value === 'string') checksum += content.value.length; + if (typeof item.props.title === 'string') checksum += item.props.title.length; + } + } + return checksum; +} + +function countItemsInRows(rows) { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} + +function rowContainsIndex(rows, index) { + return rows.some((row) => row.kind === 'items' && row.items.some((entry) => entry.globalIdx === index)); +} + +function makeVirtualSectionRow(key, top, height) { + return { + kind: 'section', + key, + sectionKey: key, + title: key, + top, + height, + }; +} + +function getVisibleRowKeys(getVisibleVirtualRows, rows, options) { + return getVisibleVirtualRows(rows, options).map((row) => row.key); +} + +function makeInstrumentedRows(rowCount) { + const stats = { topReads: 0, heightReads: 0 }; + const rowHeight = 20; + const rowGap = 4; + const rows = Array.from({ length: rowCount }, (_, index) => { + const top = index * (rowHeight + rowGap); + + return { + kind: 'section', + key: `instrumented-${index}`, + sectionKey: 'instrumented', + get top() { + stats.topReads += 1; + return top; + }, + get height() { + stats.heightReads += 1; + return rowHeight; + }, + }; + }); + + return { rows, stats }; +} + +test('Grid virtualization keeps large grids to visible cells', async (t) => { + const groups = makeLargeGridGroups(); + const eagerStart = performance.now(); + const eagerCells = countEagerRenderedCells(groups); + const checksum = simulateCellContentResolution(groups); + const eagerDuration = performance.now() - eagerStart; + + console.log( + `[grid-perf] baseline eager cells=${eagerCells} contentResolutions=${ITEM_COUNT} durationMs=${eagerDuration.toFixed(3)} checksum=${checksum}`, + ); + + assert.equal(eagerCells, ITEM_COUNT, 'legacy grid renders every cell in a large grid'); + + if (!fs.existsSync(helperPath)) { + console.log('[grid-perf] virtualization helper not present yet; baseline captured before renderer edits'); + return; + } + + const { + buildVirtualGridLayout, + countVirtualizedRowItems, + getScrollTopForItemIndex, + getVisibleVirtualRows, + } = await importTs(helperPath); + + await t.test('renders only visible item cells plus overscan', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const visibleRows = getVisibleVirtualRows(layout.rows, { + scrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const visibleCells = countVirtualizedRowItems(visibleRows); + + console.log( + `[grid-perf] virtualized visibleCells=${visibleCells} totalCells=${layout.itemCount} renderedRows=${visibleRows.length} totalRows=${layout.rows.length} totalHeight=${layout.totalHeight.toFixed(1)}`, + ); + + assert.equal(layout.itemCount, ITEM_COUNT); + assert.equal(visibleCells, countItemsInRows(visibleRows)); + assert.ok(visibleCells > 0, 'initial viewport renders visible cells'); + assert.ok(visibleCells < 80, `expected a small visible window, got ${visibleCells}`); + }); + + await t.test('scrolls selected items into the virtual window', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const targetIndex = 4242; + const scrollTop = getScrollTopForItemIndex(layout, targetIndex, { + currentScrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const selectedRows = getVisibleVirtualRows(layout.rows, { + scrollTop, + viewportHeight: VIEWPORT_HEIGHT, + }); + + assert.ok(rowContainsIndex(selectedRows, targetIndex), 'selected off-screen item is rendered after selection scroll'); + }); + + await t.test('preserves inclusive visible-row boundary semantics', () => { + const rows = [ + makeVirtualSectionRow('a', 0, 10), + makeVirtualSectionRow('b', 20, 10), + makeVirtualSectionRow('c', 40, 10), + ]; + + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 10, viewportHeight: 1, overscan: 0 }), + ['a'], + 'row whose bottom equals the visible start remains included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 15, viewportHeight: 5, overscan: 0 }), + ['b'], + 'row whose top equals the visible end remains included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 11, viewportHeight: 8, overscan: 0 }), + [], + 'rows outside the unexpanded viewport gap are excluded', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 11, viewportHeight: 8, overscan: 1 }), + ['a', 'b'], + 'overscan preserves the same inclusive start and end comparisons', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 45, viewportHeight: 1, overscan: 0 }), + ['c'], + 'partially visible rows remain included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 51, viewportHeight: 1, overscan: 0 }), + [], + 'rows whose bottom is before the visible start are excluded', + ); + }); + + await t.test('uses sublinear row-bound lookup for large row sets', () => { + const { rows, stats } = makeInstrumentedRows(100000); + const rangeStart = performance.now(); + const visibleRows = getVisibleVirtualRows(rows, { + scrollTop: 1200000, + viewportHeight: VIEWPORT_HEIGHT, + overscan: 320, + }); + const duration = performance.now() - rangeStart; + const boundReads = stats.topReads + stats.heightReads; + + console.log( + `[grid-perf] visibleRange rows=${rows.length} renderedRows=${visibleRows.length} boundReads=${boundReads} durationMs=${duration.toFixed(3)}`, + ); + + assert.ok(visibleRows.length > 0, 'large range returns visible rows'); + assert.ok(visibleRows.length < 100, `expected a small visible window, got ${visibleRows.length}`); + assert.ok(boundReads < 512, `expected logarithmic bound reads, got ${boundReads}`); + }); + + await t.test('preserves section layout options', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const sectionRow = layout.rows.find((row) => row.kind === 'items' && row.sectionKey === 'section-b'); + + assert.ok(sectionRow, 'section row exists'); + assert.equal(sectionRow.layout.columns, 4); + assert.equal(sectionRow.layout.fit, 'fill'); + assert.equal(sectionRow.layout.inset, 'lg'); + assert.notEqual(sectionRow.itemHeight, 160, 'aspect ratio creates a section-specific item row height'); + }); +}); diff --git a/scripts/test-http-download-binary-cancel.mjs b/scripts/test-http-download-binary-cancel.mjs new file mode 100644 index 00000000..3c18d32b --- /dev/null +++ b/scripts/test-http-download-binary-cancel.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { once } from 'node:events'; +import { transform } from 'esbuild'; + +async function loadHttpDownloadBinaryHandler() { + const source = await readFile('src/main/main.ts', 'utf-8'); + const start = source.indexOf(" ipcMain.handle('http-download-binary'"); + const end = source.indexOf('\n\n // Write raw binary data', start); + assert.notEqual(start, -1, 'http-download-binary handler should exist'); + assert.notEqual(end, -1, 'http-download-binary handler end marker should exist'); + + const snippet = source.slice(start, end); + const wrapper = ` + const require = globalThis.__supercmdTestRequire; + const activeHttpRequests = new Map(); + let httpDownloadBinaryHandler; + const ipcMain = { + handle(channel, handler) { + if (channel === 'http-download-binary') httpDownloadBinaryHandler = handler; + }, + }; + ${snippet} + export { activeHttpRequests, httpDownloadBinaryHandler }; + `; + const { code } = await transform(wrapper, { + loader: 'ts', + format: 'esm', + target: 'node20', + }); + + globalThis.__supercmdTestRequire = createRequire(import.meta.url); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`; + return import(moduleUrl); +} + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +async function waitUntil(predicate, timeoutMs = 1000) { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for condition'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +test('http-download-binary keeps URL-only callers working', async () => { + const { httpDownloadBinaryHandler } = await loadHttpDownloadBinaryHandler(); + const server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(Buffer.from([1, 2, 3, 4])); + }); + const port = await listen(server); + + try { + const bytes = await httpDownloadBinaryHandler({}, `http://127.0.0.1:${port}/file.bin`); + assert.deepEqual([...bytes], [1, 2, 3, 4]); + } finally { + server.close(); + await once(server, 'close'); + } +}); + +test('http-download-binary cancellation aborts transport and cleans active request map', async () => { + const { activeHttpRequests, httpDownloadBinaryHandler } = await loadHttpDownloadBinaryHandler(); + let requestClosed = false; + let markRequestStarted; + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve; + }); + const server = createServer((req, res) => { + markRequestStarted(); + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + const interval = setInterval(() => { + res.write(Buffer.alloc(16 * 1024)); + }, 5); + req.on('close', () => { + requestClosed = true; + clearInterval(interval); + }); + }); + const port = await listen(server); + const requestId = 'binary-download:test-cancel'; + + try { + const downloadPromise = httpDownloadBinaryHandler( + {}, + `http://127.0.0.1:${port}/slow.bin`, + { requestId } + ); + await waitUntil(() => activeHttpRequests.has(requestId)); + await requestStarted; + + activeHttpRequests.get(requestId)(); + + await assert.rejects(downloadPromise, { name: 'AbortError' }); + await waitUntil(() => requestClosed); + assert.equal(activeHttpRequests.has(requestId), false); + } finally { + server.close(); + await once(server, 'close'); + } +}); + +test('axios binary timeout path cancels IPC request and clears its timer', async () => { + const source = await readFile('src/renderer/src/ExtensionView.tsx', 'utf-8'); + assert.match(source, /timeoutId = setTimeout\(\(\) => \{\s*cancelDownload\(\);\s*reject\(new Error\('Binary download timed out'\)\);/s); + assert.match(source, /finally \{\s*if \(timeoutId\) clearTimeout\(timeoutId\);/s); + assert.match(source, /config\.signal\.removeEventListener\('abort', abortListener\);/); +}); diff --git a/scripts/test-http-response-decode-async.mjs b/scripts/test-http-response-decode-async.mjs new file mode 100644 index 00000000..d75bb2e8 --- /dev/null +++ b/scripts/test-http-response-decode-async.mjs @@ -0,0 +1,46 @@ +#!/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 { performance } from 'node:perf_hooks'; +import zlib from 'node:zlib'; +import { importTs } from './lib/ts-import.mjs'; + +const decodeSourcePath = path.resolve('src/main/http-response-decode.ts'); +const { decodeHttpResponseBodyBuffer } = await importTs(decodeSourcePath, { + browserGlobals: false, +}); + +function nextTimer() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('HTTP response decompression uses async zlib APIs and preserves decoded text', async () => { + const source = fs.readFileSync(decodeSourcePath, 'utf8'); + assert.equal(/(?:brotliDecompressSync|gunzipSync|inflateSync)/.test(source), false); + + const fixtureText = 'supercmd-compressed-fixture\n'.repeat(350_000); + const fixture = Buffer.from(fixtureText, 'utf8'); + const gzipFixture = zlib.gzipSync(fixture); + + const syncTimerStart = performance.now(); + const syncTimer = nextTimer().then(() => performance.now() - syncTimerStart); + zlib.gunzipSync(gzipFixture); + const syncTimerDelayMs = await syncTimer; + + const asyncTimerStart = performance.now(); + const asyncTimer = nextTimer().then(() => performance.now() - asyncTimerStart); + const decodedPromise = decodeHttpResponseBodyBuffer(gzipFixture, 'gzip'); + const asyncTimerDelayMs = await asyncTimer; + const decoded = await decodedPromise; + + assert.equal(decoded.toString('utf8'), fixtureText); + console.log(JSON.stringify({ + fixtureBytes: fixture.length, + gzipBytes: gzipFixture.length, + syncTimerDelayMs: Number(syncTimerDelayMs.toFixed(3)), + asyncTimerDelayMs: Number(asyncTimerDelayMs.toFixed(3)), + })); +}); diff --git a/scripts/test-i18n-locale-loading.mjs b/scripts/test-i18n-locale-loading.mjs new file mode 100644 index 00000000..c1734f4d --- /dev/null +++ b/scripts/test-i18n-locale-loading.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function loadBundledRuntime() { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-i18n-runtime-')); + const outfile = path.join(tempDir, 'runtime.mjs'); + await build({ + entryPoints: [path.join(root, 'src/renderer/src/i18n/runtime.ts')], + outfile, + bundle: true, + platform: 'browser', + format: 'esm', + loader: { + '.json': 'json', + }, + logLevel: 'silent', + }); + + return import(`${pathToFileURL(outfile).href}?${Date.now()}`); +} + +test('locale catalogs load asynchronously and fall back to English before loading', async () => { + const runtime = await loadBundledRuntime(); + + assert.equal(runtime.isAppLocaleLoaded('en'), true); + assert.equal(runtime.isAppLocaleLoaded('de'), false); + assert.equal(runtime.translateMessage('de', 'settings.general.language.title'), 'Language'); + + await runtime.loadAppLocale('de'); + + assert.equal(runtime.isAppLocaleLoaded('de'), true); + assert.equal(runtime.translateMessage('de', 'settings.general.language.title'), 'Sprache'); +}); + +test('English fallback preserves interpolation for unloaded locales', async () => { + const runtime = await loadBundledRuntime(); + + assert.equal( + runtime.translateMessage('fr', 'settings.general.about.version', { version: '1.2.3' }), + 'SuperCmd v1.2.3' + ); +}); diff --git a/scripts/test-icon-asset-cache.mjs b/scripts/test-icon-asset-cache.mjs new file mode 100644 index 00000000..d410cf87 --- /dev/null +++ b/scripts/test-icon-asset-cache.mjs @@ -0,0 +1,203 @@ +#!/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'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function createIconRuntimeHarness() { + const moduleCache = new Map(); + const existingPaths = new Set(); + let statCalls = 0; + let nowMs = 0; + + const windowStub = { + electron: { + statSync(filePath) { + statCalls += 1; + const exists = existingPaths.has(filePath); + return { + exists, + isDirectory: false, + isFile: exists, + size: exists ? 123 : 0, + }; + }, + }, + }; + const performanceStub = { + now() { + return nowMs; + }, + }; + + function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + window: windowStub, + performance: performanceStub, + document: { + documentElement: { + classList: { + contains: () => false, + }, + }, + }, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + config: loadTsModule('src/renderer/src/raycast-api/icon-runtime-config.ts'), + addExistingPath(filePath) { + existingPaths.add(filePath); + }, + advanceTime(ms) { + nowMs += ms; + }, + get statCalls() { + return statCalls; + }, + }; +} + +test('icon asset existence cache', async (t) => { + await t.test('caches positive extension-relative asset checks by local path', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/icon.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + for (let i = 0; i < 5; i += 1) { + assert.equal(harness.assets.resolveIconSrc('icon.png', assetsPath), expectedUrl); + } + + assert.equal(harness.statCalls, 1); + }); + + await t.test('shares positive cache entries across sc-asset and absolute path resolution', () => { + const harness = createIconRuntimeHarness(); + const iconPath = '/tmp/supercmd-assets/shared icon.png'; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + assert.equal(harness.assets.resolveIconSrc(expectedUrl), expectedUrl); + assert.equal(harness.assets.resolveIconSrc(iconPath), expectedUrl); + + assert.equal(harness.statCalls, 1); + }); + + await t.test('does not permanently cache misses so newly available assets resolve after the stale window', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/late.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), ''); + harness.addExistingPath(iconPath); + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), ''); + + harness.advanceTime(300); + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), expectedUrl); + + assert.equal(harness.statCalls, 2); + }); + + await t.test('uses configured assetsPath fallback without changing asset URL semantics', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-extension-assets'; + const iconPath = `${assetsPath}/nested/icon dark.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + harness.config.configureIconRuntime({ + getExtensionContext: () => ({ assetsPath }), + }); + + assert.equal(harness.assets.resolveIconSrc('nested/icon dark.png'), expectedUrl); + assert.equal(expectedUrl, 'sc-asset://ext-asset/tmp/supercmd-extension-assets/nested/icon%20dark.png'); + assert.equal(harness.statCalls, 1); + }); + + await t.test('repeated positive measurement avoids repeated statSync calls', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/measured.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + const iterations = 10_000; + harness.addExistingPath(iconPath); + + const start = performance.now(); + let result = ''; + for (let i = 0; i < iterations; i += 1) { + result = harness.assets.resolveIconSrc('measured.png', assetsPath); + } + const elapsedMs = performance.now() - start; + + assert.equal(result, expectedUrl); + assert.equal(harness.statCalls, 1); + console.log(`icon-asset-cache measurement: iterations=${iterations} statSync=${harness.statCalls} elapsedMs=${elapsedMs.toFixed(3)}`); + }); + + await t.test('repeated missing measurement avoids repeated statSync calls within the stale window', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iterations = 10_000; + + const start = performance.now(); + let result = 'not-run'; + for (let i = 0; i < iterations; i += 1) { + result = harness.assets.resolveIconSrc('missing.png', assetsPath); + } + const elapsedMs = performance.now() - start; + + assert.equal(result, ''); + assert.equal(harness.statCalls, 1); + console.log(`icon-asset-miss-cache measurement: iterations=${iterations} statSync=${harness.statCalls} elapsedMs=${elapsedMs.toFixed(3)}`); + }); +}); diff --git a/scripts/test-icon-runtime-file-icon-cache.mjs b/scripts/test-icon-runtime-file-icon-cache.mjs new file mode 100644 index 00000000..1402be42 --- /dev/null +++ b/scripts/test-icon-runtime-file-icon-cache.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-render.tsx'); +let importNonce = 0; + +const REACT_EFFECT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: (effect) => { + effect(); + }, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const REACT_DOM_SERVER_STUB = ` +export function renderToStaticMarkup() { + return ""; +} +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const PHOSPHOR_STUB = ` +module.exports = {}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function installBrowserGlobals(electron) { + const document = { + documentElement: { + classList: { + contains: () => false, + }, + }, + body: { + appendChild: () => {}, + removeChild: () => {}, + }, + createElement: () => ({ + style: {}, + setAttribute: () => {}, + appendChild: () => {}, + remove: () => {}, + }), + }; + + globalThis.document = document; + globalThis.window = { + document, + electron, + matchMedia: () => ({ matches: true }), + getComputedStyle: () => ({ + color: 'rgb(255, 255, 255)', + getPropertyValue: () => '', + }), + }; +} + +function exposeIconRuntimeRenderHooksPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-runtime-render-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-render\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimeRender = { + FILE_ICON_CACHE_MAX_ENTRIES, + FileIcon, + fileIconCache, + inFlightFileIconRequests, + loadFileIconDataUrl, + readCachedFileIcon, + renderIcon, + clearFileIconCaches: () => { + fileIconCache.clear(); + inFlightFileIconRequests.clear(); + }, + cacheStats: () => ({ + cacheSize: fileIconCache.size, + inFlightSize: inFlightFileIconRequests.size, + keys: Array.from(fileIconCache.keys()), + limit: FILE_ICON_CACHE_MAX_ENTRIES, + }), +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function iconRuntimeStubPlugin() { + const stubs = new Map([ + ['@phosphor-icons/react', PHOSPHOR_STUB], + ['react', REACT_EFFECT_STUB], + ['react-dom/server', REACT_DOM_SERVER_STUB], + ['react/jsx-dev-runtime', JSX_RUNTIME_STUB], + ['react/jsx-runtime', JSX_RUNTIME_STUB], + ]); + + return { + name: 'icon-runtime-file-cache-stubs', + setup(buildApi) { + buildApi.onResolve({ filter: /.*/ }, (args) => { + if (!stubs.has(args.path)) return null; + return { path: args.path, namespace: 'icon-runtime-file-cache-stub' }; + }); + + buildApi.onLoad({ filter: /.*/, namespace: 'icon-runtime-file-cache-stub' }, (args) => ({ + contents: stubs.get(args.path) || '', + loader: 'js', + })); + }, + }; +} + +async function importIconRuntimeHarness(electron = {}) { + installBrowserGlobals(electron); + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + logLevel: 'silent', + banner: { + js: 'var window = globalThis.window; var document = globalThis.document;', + }, + plugins: [iconRuntimeStubPlugin(), exposeIconRuntimeRenderHooksPlugin()], + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#icon-runtime-file-cache-${importNonce++}`, + ].join(''); + const module = await import(dataUrl); + const runtime = module.__testIconRuntimeRender; + assert.ok(runtime, 'expected icon runtime render test hooks to be exposed'); + runtime.clearFileIconCaches(); + return runtime; +} + +test('file icon runtime cache', async (t) => { + await t.test('coalesces 100 concurrently mounted identical file icons into one IPC call', async () => { + const filePath = '/tmp/supercmd-same-file.txt'; + const dataUrl = 'data:image/png;base64,same-file'; + const gate = deferred(); + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + getFileIconDataUrl(requestedPath, size) { + calls += 1; + assert.equal(requestedPath, filePath); + assert.equal(size, 20); + return gate.promise; + }, + statSync: () => ({ exists: true, isDirectory: false }), + }); + + for (let index = 0; index < 100; index += 1) { + runtime.FileIcon({ filePath, className: 'w-4 h-4' }); + } + + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().inFlightSize, 1); + + const pending = Array.from(runtime.inFlightFileIconRequests.values())[0]; + gate.resolve(dataUrl); + await pending; + + assert.equal(runtime.cacheStats().cacheSize, 1); + assert.equal(runtime.cacheStats().inFlightSize, 0); + assert.equal(runtime.readCachedFileIcon(filePath), dataUrl); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), dataUrl); + assert.equal(calls, 1, 'cached file icon should not issue another IPC call'); + }); + + await t.test('does not keep rejected IPC results as permanent negative cache entries', async () => { + const filePath = '/tmp/supercmd-late-success.txt'; + const recoveredDataUrl = 'data:image/png;base64,recovered'; + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + if (calls === 1) throw new Error('temporary icon IPC failure'); + return recoveredDataUrl; + }, + }); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), null); + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().cacheSize, 0); + assert.equal(runtime.cacheStats().inFlightSize, 0); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(runtime.cacheStats().cacheSize, 1); + }); + + await t.test('caps unique file icon cache entries and evicts least recently used paths', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl(filePath) { + calls += 1; + return `data:image/png;base64,${Buffer.from(filePath).toString('base64')}`; + }, + }); + + const { limit } = runtime.cacheStats(); + assert.ok(limit < 5_000, 'test expects a bounded cache below the old 5k growth case'); + + for (let index = 0; index < limit; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + assert.equal(runtime.cacheStats().cacheSize, limit); + + const firstPath = '/tmp/supercmd-icon-0'; + const secondPath = '/tmp/supercmd-icon-1'; + const overflowPath = `/tmp/supercmd-icon-${limit}`; + assert.ok(runtime.readCachedFileIcon(firstPath), 'reading should refresh LRU order'); + + await runtime.loadFileIconDataUrl(overflowPath); + + const afterOverflow = runtime.cacheStats(); + assert.equal(afterOverflow.cacheSize, limit); + assert.equal(calls, limit + 1); + assert.ok(afterOverflow.keys.includes(firstPath), 'recently read path should survive overflow'); + assert.ok(!afterOverflow.keys.includes(secondPath), 'least recently used path should be evicted'); + assert.ok(afterOverflow.keys.includes(overflowPath), 'new path should be cached'); + + for (let index = limit + 1; index < 5_000; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + + assert.equal(runtime.cacheStats().cacheSize, limit); + }); + + await t.test('leaves non-file image icons on the existing render path without IPC', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + return 'data:image/png;base64,file-icon'; + }, + }); + + const remoteIcon = runtime.renderIcon('https://example.com/icon.png', 'w-5 h-5'); + + assert.equal(calls, 0); + assert.equal(remoteIcon?.props?.src, 'https://example.com/icon.png'); + assert.equal(remoteIcon?.props?.className, 'w-5 h-5'); + }); +}); diff --git a/scripts/test-icon-runtime-phosphor-cache.mjs b/scripts/test-icon-runtime-phosphor-cache.mjs new file mode 100644 index 00000000..ee5b2734 --- /dev/null +++ b/scripts/test-icon-runtime-phosphor-cache.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); + +async function importIconRuntimeForTest() { + const outputFile = path.join(os.tmpdir(), `supercmd-icon-runtime-test-${process.pid}-${Date.now()}.mjs`); + const normalizedTarget = path.normalize(targetFile); + + try { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [ + { + name: 'expose-icon-runtime-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimePhosphor = { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches: () => { + phosphorIconResolutionCache.clear(); + phosphorNameResolutionCache.clear(); + raycastIconNameResolutionCache.clear(); + }, + caches: { + phosphorIconResolutionCache, + phosphorNameResolutionCache, + raycastIconNameResolutionCache, + }, +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }, + { + name: 'stub-server-rendering-for-icon-runtime-tests', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-test-stub', + namespace: 'test-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'test-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }, + ], + }); + + const runtime = await import(`${pathToFileURL(outputFile).href}?t=${Date.now()}`); + return runtime.__testIconRuntimePhosphor; + } finally { + await fs.unlink(outputFile).catch(() => {}); + } +} + +test('Phosphor icon runtime resolution cache', async (t) => { + const iconRuntime = await importIconRuntimeForTest(); + const { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches, + caches, + } = iconRuntime; + + await t.test('resolves direct Raycast icon names without changing weight', () => { + clearIconResolutionCaches(); + + const expected = tryResolvePhosphorByName('MagnifyingGlass'); + const resolved = resolvePhosphorIconFromRaycast('MagnifyingGlass'); + + assert.ok(expected, 'expected Phosphor MagnifyingGlass export to exist'); + assert.equal(resolved?.icon, expected); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('preserves explicit Raycast aliases and filled weight compatibility', () => { + clearIconResolutionCaches(); + + const stopwatch = resolvePhosphorIconFromRaycast('Stopwatch'); + assert.equal(stopwatch?.icon, tryResolvePhosphorByName('Timer')); + assert.equal(stopwatch?.weight, 'regular'); + + const filled = resolvePhosphorIconFromRaycast('XMarkCircleFilled'); + assert.equal(filled?.icon, tryResolvePhosphorByName('XCircle')); + assert.equal(filled?.weight, 'fill'); + }); + + await t.test('keeps unknown icons on the existing fallback glyph', () => { + clearIconResolutionCaches(); + + const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); + const resolved = resolvePhosphorIconFromRaycast('QzxvPqmn999'); + + assert.ok(fallback, 'expected a Phosphor fallback glyph to exist'); + assert.equal(resolved?.icon, fallback); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('caches successful and failed resolutions while allowing whole-cache clear', () => { + clearIconResolutionCaches(); + + const first = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + const second = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(second, first, 'repeated final resolution should return the cached result object'); + + assert.equal(tryResolvePhosphorByName('DefinitelyNotAPhosphorIcon'), undefined); + assert.equal( + caches.phosphorNameResolutionCache.get('DefinitelyNotAPhosphorIcon'), + null, + 'failed Phosphor export lookups are memoized as misses', + ); + + clearIconResolutionCaches(); + assert.equal(caches.phosphorIconResolutionCache.size, 0); + assert.equal(caches.phosphorNameResolutionCache.size, 0); + assert.equal(caches.raycastIconNameResolutionCache.size, 0); + + const afterClear = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(afterClear?.icon, first?.icon); + assert.equal(afterClear?.weight, first?.weight); + }); +}); diff --git a/scripts/test-icon-tint-cache.mjs b/scripts/test-icon-tint-cache.mjs new file mode 100644 index 00000000..a2f89c89 --- /dev/null +++ b/scripts/test-icon-tint-cache.mjs @@ -0,0 +1,341 @@ +#!/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'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const MEASURE_ONLY = process.env.SUPERCMD_TINT_CACHE_MEASURE_ONLY === '1'; +const ITERATIONS = 10_000; + +const NAMED_COLORS = new Map([ + ['black', { r: 0, g: 0, b: 0 }], + ['blue', { r: 0, g: 0, b: 255 }], + ['green', { r: 0, g: 128, b: 0 }], + ['red', { r: 255, g: 0, b: 0 }], + ['rebeccapurple', { r: 102, g: 51, b: 153 }], + ['white', { r: 255, g: 255, b: 255 }], +]); + +function parseRgbTriplet(value) { + const match = String(value).match(/^rgba?\(([^)]+)\)$/i); + if (!match) return null; + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; + return { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; +} + +function parseHexColor(value) { + const hex = String(value).trim(); + const match = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!match) return null; + const raw = match[1]; + const normalized = raw.length === 3 + ? raw.split('').map((part) => `${part}${part}`).join('') + : raw.slice(0, 6); + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; +} + +function toCssRgb(color) { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function createStyleDeclaration() { + let color = ''; + return { + position: '', + visibility: '', + pointerEvents: '', + get color() { + return color; + }, + set color(value) { + const raw = String(value || '').trim(); + if (!raw) { + color = ''; + return; + } + if (parseHexColor(raw) || parseRgbTriplet(raw) || NAMED_COLORS.has(raw.toLowerCase()) || /^var\(--[^)]+\)$/i.test(raw)) { + color = raw; + return; + } + color = ''; + }, + }; +} + +function resolveComputedColor(value, cssVariables) { + const raw = String(value || '').trim(); + const variableMatch = raw.match(/^var\((--[^)]+)\)$/i); + const color = variableMatch ? String(cssVariables.get(variableMatch[1]) || '').trim() : raw; + const rgb = parseHexColor(color) || parseRgbTriplet(color) || NAMED_COLORS.get(color.toLowerCase()); + return rgb ? toCssRgb(rgb) : color; +} + +function createIconTintHarness() { + const moduleCache = new Map(); + const cssVariables = new Map([ + ['--surface-base-rgb', '247, 248, 250'], + ['--accent-color', '#336699'], + ]); + let dark = false; + let rootStyleVersion = 0; + let elementCreations = 0; + let bodyAppends = 0; + let computedStyleCalls = 0; + + const documentElement = { + get className() { + return dark ? 'dark' : ''; + }, + classList: { + contains(className) { + return className === 'dark' && dark; + }, + }, + getAttribute(attributeName) { + if (attributeName !== 'style') return ''; + return `--style-version: ${rootStyleVersion}`; + }, + }; + + const documentStub = { + documentElement, + body: { + appendChild(element) { + bodyAppends += 1; + element.parentNode = this; + }, + }, + createElement() { + elementCreations += 1; + return { + parentNode: null, + style: createStyleDeclaration(), + remove() { + this.parentNode = null; + }, + }; + }, + }; + + const windowStub = { + getComputedStyle(target) { + computedStyleCalls += 1; + if (target === documentElement) { + return { + color: dark ? 'rgb(255, 255, 255)' : 'rgb(17, 23, 32)', + getPropertyValue(variableName) { + return cssVariables.get(variableName) || ''; + }, + }; + } + return { + color: resolveComputedColor(target?.style?.color, cssVariables), + getPropertyValue() { + return ''; + }, + }; + }, + }; + + class MutationObserverStub { + observe() {} + disconnect() {} + } + + function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + window: windowStub, + document: documentStub, + MutationObserver: MutationObserverStub, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + function setDark(nextDark) { + dark = nextDark; + cssVariables.set('--surface-base-rgb', dark ? '30, 31, 36' : '247, 248, 250'); + } + + function setCssVariable(variableName, value) { + cssVariables.set(variableName, value); + rootStyleVersion += 1; + } + + function resetCounts() { + elementCreations = 0; + bodyAppends = 0; + computedStyleCalls = 0; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + setDark, + setCssVariable, + resetCounts, + get counts() { + return { + elementCreations, + bodyAppends, + computedStyleCalls, + }; + }, + }; +} + +function measureScenario(name, scenario) { + const harness = createIconTintHarness(); + const start = performance.now(); + const result = scenario(harness); + const elapsedMs = performance.now() - start; + const counts = harness.counts; + console.log( + `icon-tint-cache measurement: scenario=${name} iterations=${ITERATIONS} elementCreations=${counts.elementCreations} bodyAppends=${counts.bodyAppends} getComputedStyle=${counts.computedStyleCalls} elapsedMs=${elapsedMs.toFixed(3)}`, + ); + return { result, counts }; +} + +function assertCacheCount(actual, max, label) { + if (MEASURE_ONLY) return; + assert.ok(actual <= max, `${label}: expected <= ${max}, got ${actual}`); +} + +test('icon tint color cache', async (t) => { + await t.test('preserves string, hex, invalid, and object tint semantics', () => { + const harness = createIconTintHarness(); + const { resolveTintColor } = harness.assets; + + assert.equal(resolveTintColor('336699'), '#336699'); + assert.equal(resolveTintColor('#336699'), '#336699'); + assert.equal(resolveTintColor('rebeccapurple'), 'rebeccapurple'); + assert.equal(resolveTintColor('definitely-not-a-color'), undefined); + + const tint = { light: 'red', dark: 'blue' }; + assert.equal(resolveTintColor(tint), 'red'); + harness.setDark(true); + assert.equal(resolveTintColor(tint), 'blue'); + + assert.equal(resolveTintColor({ light: 'red' }), 'red'); + harness.setDark(false); + assert.equal(resolveTintColor({ dark: 'blue' }), 'blue'); + }); + + await t.test('caches repeated string tint validation', () => { + const { result, counts } = measureScenario('resolveTintColor-string', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor('336699'); + } + return resolved; + }); + + assert.equal(result, '#336699'); + assertCacheCount(counts.elementCreations, 1, 'string tint validation element creations'); + }); + + await t.test('caches repeated object tint validation per selected theme color', () => { + const { result, counts } = measureScenario('resolveTintColor-object-light-dark', (harness) => { + const tint = { light: 'red', dark: 'blue' }; + let resolved = ''; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + harness.setDark(true); + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + return resolved; + }); + + assert.equal(result, 'blue'); + assertCacheCount(counts.elementCreations, 2, 'object tint validation element creations'); + }); + + await t.test('caches readable tint DOM parsing and root CSS variable reads', () => { + const { result, counts } = measureScenario('resolveReadableTintColor-repeat', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveReadableTintColor('#777777', { minContrast: 4.25 }); + } + return resolved; + }); + + assert.equal(typeof result, 'string'); + assertCacheCount(counts.elementCreations, 2, 'readable tint element creations'); + assertCacheCount(counts.computedStyleCalls, 2, 'readable tint getComputedStyle calls'); + }); + + await t.test('invalidates readable tint results when theme or root style changes', () => { + const harness = createIconTintHarness(); + const { resolveReadableTintColor } = harness.assets; + + const light = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const lightCounts = harness.counts; + harness.setDark(true); + const dark = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const darkCounts = harness.counts; + harness.setCssVariable('--surface-base-rgb', '5, 5, 5'); + const darker = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + + assert.notEqual(light, dark); + assert.equal(typeof dark, 'string'); + assert.equal(darker, '#777777'); + assert.notEqual(dark, darker); + assert.ok(darkCounts.computedStyleCalls > lightCounts.computedStyleCalls); + assert.ok(harness.counts.computedStyleCalls > darkCounts.computedStyleCalls); + }); +}); diff --git a/scripts/test-inline-args-write-coalesce.mjs b/scripts/test-inline-args-write-coalesce.mjs new file mode 100644 index 00000000..b90ef672 --- /dev/null +++ b/scripts/test-inline-args-write-coalesce.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; + +const bundledPreferences = await build({ + entryPoints: ['src/renderer/src/utils/extension-preferences.ts'], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + logLevel: 'silent', +}); + +const bundledPreferencesUrl = + 'data:text/javascript;base64,' + Buffer.from(bundledPreferences.outputFiles[0].text).toString('base64'); + +let moduleCounter = 0; + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function copyPayload(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function loadPreferencesWithMocks() { + const storage = new Map(); + const commandArgumentWrites = []; + + globalThis.localStorage = { + getItem: (key) => (storage.has(key) ? storage.get(key) : null), + setItem: (key, value) => { + storage.set(key, String(value)); + }, + removeItem: (key) => { + storage.delete(key); + }, + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { + return storage.size; + }, + }; + globalThis.window = { + electron: { + saveExtensionCommandArguments: async (args) => { + commandArgumentWrites.push(copyPayload(args)); + return {}; + }, + saveExtensionPreferences: async (args) => { + return {}; + }, + }, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + dispatchEvent: () => true, + }; + globalThis.CustomEvent = class CustomEvent { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } + }; + + moduleCounter += 1; + const preferences = await import(`${bundledPreferencesUrl}#test-${moduleCounter}`); + return { preferences, storage, commandArgumentWrites }; +} + +test('inline command argument settings mirror coalesces rapid writes', async () => { + const { preferences, storage, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('coalesce-extension', 'no-view-command'); + const values = ['s', 'se', 'sea', 'sear', 'searc', 'search', 'search ', 'search t', 'search te', 'search tex', 'search text']; + + for (const value of values) { + preferences.writeJsonObject( + key, + { query: value }, + { commandArgumentSettingsSync: 'debounced' } + ); + } + + assert.equal(JSON.parse(storage.get(key)).query, 'search text'); + assert.equal(commandArgumentWrites.length, 0); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'coalesce-extension', + cmdName: 'no-view-command', + values: { query: 'search text' }, + }); +}); + +test('launch flush writes pending inline command arguments immediately once', async () => { + const { preferences, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('flush-extension', 'no-view-command'); + + preferences.writeJsonObject( + key, + { query: 'stale' }, + { commandArgumentSettingsSync: 'debounced' } + ); + preferences.writeJsonObject( + key, + { query: 'fresh at launch' }, + { commandArgumentSettingsSync: 'debounced' } + ); + + await preferences.flushCommandArgumentSettingsSync(key); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'flush-extension', + cmdName: 'no-view-command', + values: { query: 'fresh at launch' }, + }); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + assert.equal(commandArgumentWrites.length, 1); +}); + +test('immediate command argument writes cancel pending debounced settings sync', async () => { + const { preferences, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('submit-extension', 'no-view-command'); + + preferences.writeJsonObject( + key, + { query: 'pending inline value' }, + { commandArgumentSettingsSync: 'debounced' } + ); + preferences.writeJsonObject(key, { query: 'submitted value' }); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'submit-extension', + cmdName: 'no-view-command', + values: { query: 'submitted value' }, + }); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + assert.equal(commandArgumentWrites.length, 1); +}); diff --git a/scripts/test-inline-emoji-picker-match-cache.mjs b/scripts/test-inline-emoji-picker-match-cache.mjs new file mode 100644 index 00000000..c1a0309c --- /dev/null +++ b/scripts/test-inline-emoji-picker-match-cache.mjs @@ -0,0 +1,290 @@ +#!/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'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const mainPath = path.join(repoRoot, 'src/main/main.ts'); +const emojiDataPath = path.join(repoRoot, 'src/main/emoji-data.json'); + +function extractEmojiPickerSource(source) { + const start = source.indexOf('type EmojiEntry = '); + const end = source.indexOf('\nfunction stopEmojiTriggerMonitor', start); + assert.notEqual(start, -1, 'Could not find inline emoji picker source start'); + assert.notEqual(end, -1, 'Could not find inline emoji picker source end'); + return source.slice(start, end); +} + +function instrumentEmojiPickerSource(source) { + let instrumented = source.replace( + 'let emojiTriggerData: EmojiEntry[] | null = null;', + 'let emojiTriggerData: EmojiEntry[] | null = __emojiData;' + ); + instrumented = instrumented.replace( + 'function searchEmojiTriggerMatches(query: string, max = 8): EmojiEntry[] {\n', + 'function searchEmojiTriggerMatches(query: string, max = 8): EmojiEntry[] {\n __searchCallCount += 1;\n' + ); + + assert.notEqual(instrumented, source, 'Emoji picker source was not instrumented'); + + return ` +let __searchCallCount = 0; +const __renderCalls = []; +const __writeCommands = []; +const __insertCalls = []; +let emojiTriggerProcess = { + stdin: { + write(line) { + __writeCommands.push(JSON.parse(line)); + }, + }, +}; +let emojiPickerWindow = null; +let emojiPickerCurrentQuery = ''; +let emojiPickerCurrentPrefixLen = 1; +let emojiPickerSelectedIdx = 0; +let emojiPickerCurrentMatches = []; +const path = { join: (...parts) => parts.join('/') }; +const app = { getAppPath: () => '' }; +const screen = { + getCursorScreenPoint: () => ({ x: 0, y: 0 }), + getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), +}; +const BrowserWindow = class {}; +const systemClipboard = { readText: () => '', writeText: () => {} }; +function loadSettings() { + return { emojiPickerExcludedAppBundleIds: [] }; +} +function disableWindowAnimation() {} +function createFakeEmojiWindow() { + return { + visible: true, + bounds: null, + isDestroyed() { return false; }, + isVisible() { return this.visible; }, + showInactive() { this.visible = true; }, + hide() { this.visible = false; }, + getSize() { return [340, 80]; }, + setBounds(bounds) { this.bounds = bounds; }, + webContents: { + executeJavaScript(js) { + __renderCalls.push(js); + return Promise.resolve(undefined); + }, + setBackgroundThrottling() {}, + }, + }; +} + +${instrumented} + +insertEmojiReplacingTrigger = async function(emoji, queryLen, prefixLen) { + __insertCalls.push({ emoji, queryLen, prefixLen }); +}; + +globalThis.__emojiPickerTestAccess = { + reset() { + emojiPickerCurrentQuery = ''; + emojiPickerCurrentPrefixLen = 1; + emojiPickerSelectedIdx = 0; + emojiPickerCurrentMatches = []; + emojiPickerWindow = createFakeEmojiWindow(); + __searchCallCount = 0; + __renderCalls.length = 0; + __writeCommands.length = 0; + __insertCalls.length = 0; + }, + async render(query, prefixLen = 1) { + await renderEmojiPicker(query, { x: 80, y: 120, w: 10, h: 18 }, prefixLen, 'com.supercmd.test'); + }, + nav(key) { + handleEmojiTriggerNav(key); + }, + hide() { + hideEmojiPicker(); + }, + search(query) { + return searchEmojiTriggerMatches(query); + }, + resetSearchCount() { + __searchCallCount = 0; + }, + state() { + return { + query: emojiPickerCurrentQuery, + prefixLen: emojiPickerCurrentPrefixLen, + selectedIdx: emojiPickerSelectedIdx, + matchNames: emojiPickerCurrentMatches.map((entry) => entry.name), + matchEmojis: emojiPickerCurrentMatches.map((entry) => entry.emoji), + searchCallCount: __searchCallCount, + renderCalls: __renderCalls.slice(), + writeCommands: __writeCommands.slice(), + insertCalls: __insertCalls.slice(), + windowVisible: Boolean(emojiPickerWindow?.visible), + }; + }, +}; +`; +} + +function loadEmojiPickerHarness() { + const source = fs.readFileSync(mainPath, 'utf8'); + const emojiData = JSON.parse(fs.readFileSync(emojiDataPath, 'utf8')).map((entry) => ({ + name: entry.n, + emoji: entry.e, + keywords: entry.k || [], + })); + const instrumented = instrumentEmojiPickerSource(extractEmojiPickerSource(source)); + const transpiled = ts.transpileModule(instrumented, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: 'inline-emoji-picker-harness.ts', + }); + const sandbox = { + __emojiData: emojiData, + console, + JSON, + Promise, + require, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: 'inline-emoji-picker-harness.js' }); + return { hooks: sandbox.__emojiPickerTestAccess, emojiData }; +} + +function toPlain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function measureRepeatedNavigationSearchWork(search, query, { sequences, steps }) { + let legacyChecksum = 0; + let legacySearchCalls = 0; + const legacyStart = performance.now(); + for (let sequence = 0; sequence < sequences; sequence += 1) { + let selectedIdx = 0; + for (let step = 0; step < steps; step += 1) { + const matchesForHandle = search(query); + legacySearchCalls += 1; + if (matchesForHandle.length === 0) break; + const matchesForUpdate = search(query); + legacySearchCalls += 1; + selectedIdx = (selectedIdx + 1 + matchesForUpdate.length) % matchesForUpdate.length; + legacyChecksum += selectedIdx + matchesForUpdate.length; + } + } + const legacyMs = performance.now() - legacyStart; + + let cachedChecksum = 0; + let cachedSearchCalls = 0; + const cachedStart = performance.now(); + for (let sequence = 0; sequence < sequences; sequence += 1) { + const matches = search(query); + cachedSearchCalls += 1; + let selectedIdx = 0; + for (let step = 0; step < steps; step += 1) { + selectedIdx = (selectedIdx + 1 + matches.length) % matches.length; + cachedChecksum += selectedIdx + matches.length; + } + } + const cachedMs = performance.now() - cachedStart; + + return { + query, + sequences, + steps, + legacy: { + searchCalls: legacySearchCalls, + totalMs: Number(legacyMs.toFixed(3)), + perSequenceMs: Number((legacyMs / sequences).toFixed(4)), + checksum: legacyChecksum, + }, + cached: { + searchCalls: cachedSearchCalls, + totalMs: Number(cachedMs.toFixed(3)), + perSequenceMs: Number((cachedMs / sequences).toFixed(4)), + checksum: cachedChecksum, + }, + searchCallReduction: Number((legacySearchCalls / cachedSearchCalls).toFixed(1)), + }; +} + +test('inline emoji picker reuses rendered matches for navigation and insertion', async () => { + const { hooks } = loadEmojiPickerHarness(); + + hooks.reset(); + await hooks.render('smi'); + const rendered = hooks.state(); + assert.equal(rendered.searchCallCount, 1, 'render should search once for the new query'); + assert.equal(rendered.matchNames.length, 8, 'fixture query should produce a full picker row'); + + hooks.resetSearchCount(); + for (let index = 0; index < 20; index += 1) { + hooks.nav('right'); + } + const afterRights = hooks.state(); + assert.equal(afterRights.searchCallCount, 0, 'right navigation should not rescan emoji data'); + assert.equal(afterRights.selectedIdx, 20 % rendered.matchNames.length); + + hooks.nav('left'); + const afterLeft = hooks.state(); + assert.equal(afterLeft.searchCallCount, 0, 'left navigation should not rescan emoji data'); + assert.equal(afterLeft.selectedIdx, (afterRights.selectedIdx - 1 + rendered.matchNames.length) % rendered.matchNames.length); + + hooks.nav('enter'); + const afterEnter = hooks.state(); + assert.equal(afterEnter.searchCallCount, 0, 'enter should reuse the rendered match list'); + assert.deepEqual(toPlain(afterEnter.insertCalls), [{ + emoji: rendered.matchEmojis[afterLeft.selectedIdx], + queryLen: 3, + prefixLen: 1, + }]); + assert.deepEqual(toPlain(afterEnter.matchNames), [], 'hide should clear cached matches after insertion'); + assert.equal(afterEnter.query, ''); +}); + +test('inline emoji picker clears cached matches when a query has no matches', async () => { + const { hooks } = loadEmojiPickerHarness(); + + hooks.reset(); + await hooks.render('smi'); + assert.ok(hooks.state().matchNames.length > 0); + + await hooks.render('zzzzzzzzzz'); + const state = hooks.state(); + assert.equal(state.searchCallCount, 2); + assert.deepEqual(toPlain(state.matchNames), []); + assert.equal(state.query, ''); + assert.equal(state.windowVisible, false); +}); + +test('inline emoji picker repeated navigation search work is cached', () => { + const { hooks, emojiData } = loadEmojiPickerHarness(); + const measurement = measureRepeatedNavigationSearchWork(hooks.search, 'smi', { + sequences: 500, + steps: 20, + }); + + console.log(JSON.stringify({ + inlineEmojiPickerMatchCache: { + emojiEntries: emojiData.length, + ...measurement, + }, + }, null, 2)); + + assert.equal(emojiData.length, 1870); + assert.equal(measurement.legacy.searchCalls, measurement.sequences * measurement.steps * 2); + assert.equal(measurement.cached.searchCalls, measurement.sequences); + assert.equal(measurement.searchCallReduction, 40); + assert.equal(measurement.cached.checksum, measurement.legacy.checksum); +}); diff --git a/scripts/test-launcher-command-list-windowing.mjs b/scripts/test-launcher-command-list-windowing.mjs new file mode 100644 index 00000000..b34dc9d0 --- /dev/null +++ b/scripts/test-launcher-command-list-windowing.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function runMeasurement() { + const output = execFileSync( + process.execPath, + [ + 'scripts/measure-launcher-command-list-render.mjs', + '--rows=5000', + '--iterations=1', + '--warmups=0', + '--json', + ], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + } + ); + const jsonStart = output.lastIndexOf('{\n "rowCount"'); + assert.notEqual(jsonStart, -1, `Measurement output did not include JSON summary:\n${output}`); + return JSON.parse(output.slice(jsonStart)); +} + +test('launcher command list windows large result sets instead of rendering every row', () => { + const metrics = runMeasurement(); + const fullSizeScenarios = metrics.results.filter((result) => result.commandCount === 5000); + assert.equal(fullSizeScenarios.length, 4); + + for (const result of fullSizeScenarios) { + assert.ok( + result.rowRenderCount < 100, + `${result.name} rendered ${result.rowRenderCount} rows for ${result.commandCount} commands` + ); + } + + assert.ok( + metrics.totalRows < 300, + `Expected the full scenario sequence to render fewer than 300 rows, got ${metrics.totalRows}` + ); +}); diff --git a/scripts/test-launcher-file-result-command-map.mjs b/scripts/test-launcher-file-result-command-map.mjs new file mode 100644 index 00000000..d627ecbf --- /dev/null +++ b/scripts/test-launcher-file-result-command-map.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { build } from 'esbuild'; + +const root = path.resolve('.'); +const { + buildLauncherFileCandidates, + buildLauncherFileResultCommandByPath, +} = await importBundledTs(path.join(root, 'src/renderer/src/utils/launcher-file-candidates.ts')); + +async function importBundledTs(absPath) { + const result = await build({ + absWorkingDir: root, + entryPoints: [absPath], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + '.js': 'js', + '.jsx': 'jsx', + '.json': 'json', + }, + }); + const code = result.outputFiles[0].text; + const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`; + return import(dataUrl); +} + +function makeResult(index, overrides = {}) { + const parentPath = `/Users/test/Documents/project-${String(index % 30).padStart(2, '0')}`; + const name = `alpha-file-${String(index).padStart(4, '0')}.txt`; + const filePath = `${parentPath}/${name}`; + return { + path: filePath, + name, + parentPath, + displayPath: `~/Documents/project-${String(index % 30).padStart(2, '0')}`, + isDirectory: false, + mtimeMs: Date.now() - (index % 24) * 60 * 60 * 1000, + birthtimeMs: Date.now() - (index % 24) * 60 * 60 * 1000, + topLevelRoot: 'Documents', + homeRelativeDepth: 3, + depth: 3, + noisyPathSegmentCount: 0, + ...overrides, + }; +} + +function makeCommand(result, index, overrides = {}) { + return { + id: `system-file-result:${index}`, + title: result.name, + subtitle: result.displayPath, + keywords: [result.name, result.parentPath, result.displayPath], + category: 'system', + path: result.path, + ...overrides, + }; +} + +function candidateSignature(candidates) { + return candidates.map((candidate) => [ + candidate.stableKey, + candidate.label, + candidate.pathOrUrl, + candidate.matchKind, + candidate.matchScore, + candidate.subtype, + candidate.command.id, + candidate.command.title, + candidate.command.path, + candidate.finalScore, + ]); +} + +function checksumCandidates(candidates) { + let checksum = candidates.length; + for (const candidate of candidates) { + checksum += candidate.command.id.length; + checksum += candidate.stableKey.length; + checksum += Math.round(candidate.finalScore); + } + return checksum; +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +function measure(iterations, fn) { + const times = []; + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const started = performance.now(); + checksum += checksumCandidates(fn()); + times.push(performance.now() - started); + } + return { ...stats(times), checksum }; +} + +test('launcher file command map preserves first path match and result order', () => { + const duplicatePath = '/Users/test/Documents/alpha-duplicate.txt'; + const duplicateResult = makeResult(1, { + path: duplicatePath, + name: 'alpha-duplicate.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + const missingCommandResult = makeResult(2, { + path: '/Users/test/Documents/alpha-missing.txt', + name: 'alpha-missing.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + const tailResult = makeResult(3, { + path: '/Users/test/Documents/alpha-tail.txt', + name: 'alpha-tail.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + + const firstDuplicateCommand = makeCommand(duplicateResult, 1, { title: 'First duplicate command' }); + const secondDuplicateCommand = makeCommand(duplicateResult, 2, { title: 'Second duplicate command' }); + const tailCommand = makeCommand(tailResult, 3, { title: 'Tail command' }); + const commandByPath = buildLauncherFileResultCommandByPath([ + firstDuplicateCommand, + secondDuplicateCommand, + tailCommand, + ]); + + assert.equal(commandByPath.get(duplicatePath), firstDuplicateCommand); + + const candidates = buildLauncherFileCandidates({ + launcherFileResults: [duplicateResult, missingCommandResult, tailResult], + fileResultCommandByPath: commandByPath, + searchQuery: 'alpha', + rootSearchRanking: {}, + }); + + assert.deepEqual( + candidates.map((candidate) => candidate.command.title), + ['First duplicate command', 'Tail command'] + ); + assert.deepEqual( + candidates.map((candidate) => candidate.pathOrUrl), + [duplicatePath, tailResult.path] + ); +}); + +test('launcher file candidate assembly handles 3000 results with path-map lookup', () => { + const count = 3000; + const iterations = 25; + const launcherFileResults = Array.from({ length: count }, (_, index) => makeResult(index)); + const fileResultCommands = launcherFileResults.map((result, index) => makeCommand(result, index)); + const fileResultCommandByPath = buildLauncherFileResultCommandByPath(fileResultCommands); + const findBackedLookup = { + get(filePath) { + return fileResultCommands.find((command) => command.path === filePath); + }, + }; + + const input = { + launcherFileResults, + searchQuery: 'alpha', + rootSearchRanking: {}, + }; + const mapCandidates = buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath, + }); + const findCandidates = buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath: findBackedLookup, + }); + + assert.equal(mapCandidates.length, count); + assert.deepEqual(candidateSignature(mapCandidates), candidateSignature(findCandidates)); + + const mapStats = measure(iterations, () => buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath, + })); + const findStats = measure(iterations, () => buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath: findBackedLookup, + })); + + assert.equal(mapStats.checksum, findStats.checksum); + assert.ok( + mapStats.median < findStats.median, + `expected path-map assembly median ${mapStats.median}ms to be below find-backed median ${findStats.median}ms` + ); + + console.log('Launcher file candidate assembly benchmark', JSON.stringify({ + count, + iterations, + mapStats, + findStats, + speedup: Number((findStats.median / mapStats.median).toFixed(2)), + })); +}); diff --git a/scripts/test-list-conditional-virtual-rows.mjs b/scripts/test-list-conditional-virtual-rows.mjs new file mode 100644 index 00000000..8d1987f6 --- /dev/null +++ b/scripts/test-list-conditional-virtual-rows.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const LIST_RUNTIME_PATH = 'src/renderer/src/raycast-api/list-runtime.tsx'; +const CASES = [5000, 20000]; + +function buildGroupedItems(itemCount) { + return [{ + title: 'Emoji', + items: Array.from({ length: itemCount }, (_, index) => ({ + item: { id: `emoji-${index}` }, + globalIdx: index, + })), + }]; +} + +function buildFlatRows(groupedItems) { + const rows = []; + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}` }); + for (const entry of group.items) { + rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); + } + } + return rows; +} + +function buildConditionalFlatRows(groupedItems, shouldUseEmojiGridValue) { + if (shouldUseEmojiGridValue) return []; + return buildFlatRows(groupedItems); +} + +function analyzeListRuntimeSource() { + const source = fs.readFileSync(LIST_RUNTIME_PATH, 'utf8'); + return { + skipsFlatRowsForEmojiGrid: source.includes('if (shouldUseEmojiGridValue) return [];'), + tracksEmojiGridDependency: source.includes('}, [groupedItems, shouldUseEmojiGridValue]);'), + scrollsRenderedEmojiCell: source.includes('querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView'), + }; +} + +function measureAvoidedRows() { + return CASES.map((itemCount) => { + const groupedItems = buildGroupedItems(itemCount); + const oldEmojiRows = buildFlatRows(groupedItems); + const newEmojiRows = buildConditionalFlatRows(groupedItems, true); + const oldNormalRows = buildFlatRows(groupedItems); + const newNormalRows = buildConditionalFlatRows(groupedItems, false); + + return { + itemCount, + oldEmojiRowCount: oldEmojiRows.length, + newEmojiRowCount: newEmojiRows.length, + avoidedEmojiRows: oldEmojiRows.length - newEmojiRows.length, + normalRowCountPreserved: oldNormalRows.length === newNormalRows.length, + }; + }); +} + +test('list runtime skips unused linear rows for emoji-grid mode', () => { + const source = analyzeListRuntimeSource(); + assert.equal(source.skipsFlatRowsForEmojiGrid, true, 'flat row construction should short-circuit in emoji-grid mode'); + assert.equal(source.tracksEmojiGridDependency, true, 'flat row memo should update when the layout mode changes'); + assert.equal(source.scrollsRenderedEmojiCell, true, 'emoji-grid selection should still scroll the rendered selected cell'); + + const measurements = measureAvoidedRows(); + for (const measurement of measurements) { + assert.equal(measurement.newEmojiRowCount, 0, `${measurement.itemCount} emoji items should not build linear rows`); + assert.equal(measurement.avoidedEmojiRows, measurement.oldEmojiRowCount); + assert.equal(measurement.normalRowCountPreserved, true, 'normal list row count should be unchanged'); + } +}); + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ listConditionalVirtualRows: measureAvoidedRows() }, null, 2)); +} diff --git a/scripts/test-list-registry-stable-order.mjs b/scripts/test-list-registry-stable-order.mjs new file mode 100644 index 00000000..8fa88142 --- /dev/null +++ b/scripts/test-list-registry-stable-order.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const LIST_RENDERERS_PATH = 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'; +const ITEM_COUNT = 1000; +const SELECTION_MOVES = 25; + +function readListItemComponentSource() { + const source = fs.readFileSync(LIST_RENDERERS_PATH, 'utf8'); + const match = source.match(/function ListItemComponent[\s\S]*?\n }\n\n \(ListItemComponent/); + assert.ok(match, 'ListItemComponent source should be present'); + return match[0]; +} + +function getLayoutEffectDependencies(source) { + const match = source.match(/useLayoutEffect\(\(\) => \{[\s\S]*?\}, \[([^\]]*)\]\);/); + assert.ok(match, 'ListItemComponent should register through useLayoutEffect'); + return match[1] + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean); +} + +function analyzeListRegistration(source) { + const dependencies = getLayoutEffectDependencies(source); + const hasSelectedActionsContext = source.includes('useContext(SelectedItemActionsContext)'); + const hasVolatileRenderOrder = /const\s+renderOrder\s*=\s*\+\+itemOrderCounter\s*;/.test(source); + const effectDependsOnRenderOrder = dependencies.includes('renderOrder'); + const hasStableOrderRef = + /const\s+orderRef\s*=\s*useRef<\s*number\s*\|\s*null\s*>\(null\)/.test(source) && + /orderRef\.current\s*===\s*null/.test(source) && + /orderRef\.current\s*=\s*\+\+itemOrderCounter/.test(source); + + const layoutEffectRestartsOnSelectionRender = hasSelectedActionsContext && hasVolatileRenderOrder && effectDependsOnRenderOrder; + return { + dependencies, + hasSelectedActionsContext, + hasStableOrderRef, + hasVolatileRenderOrder, + effectDependsOnRenderOrder, + layoutEffectRestartsOnSelectionRender, + }; +} + +function measureSelectionChurn(analysis) { + const selectionDrivenItemRenders = ITEM_COUNT * SELECTION_MOVES; + const layoutEffectRestarts = analysis.layoutEffectRestartsOnSelectionRender ? selectionDrivenItemRenders : 0; + const registryDeletesOnSelection = layoutEffectRestarts; + const registrySetsOnSelection = layoutEffectRestarts; + return { + itemCount: ITEM_COUNT, + selectionMoves: SELECTION_MOVES, + orderMode: analysis.hasStableOrderRef ? 'stable-ref' : 'render-counter', + selectionDrivenItemRenders, + layoutEffectRestarts, + registryMutationsOnSelection: registryDeletesOnSelection + registrySetsOnSelection, + registryVersionUpdatesOnSelection: analysis.layoutEffectRestartsOnSelectionRender ? SELECTION_MOVES : 0, + }; +} + +function getMetrics() { + const source = readListItemComponentSource(); + const analysis = analyzeListRegistration(source); + return { analysis, metrics: measureSelectionChurn(analysis) }; +} + +if (process.argv.includes('--report')) { + const { analysis, metrics } = getMetrics(); + console.log(JSON.stringify({ analysis, metrics }, null, 2)); +} else { + test('List item registration order is stable across selection context renders', () => { + const { analysis, metrics } = getMetrics(); + assert.equal(analysis.hasSelectedActionsContext, true, 'List items still render selected item actions in their source context'); + assert.equal(analysis.hasStableOrderRef, true, 'List item render order should be stored in a ref like Grid items'); + assert.equal(analysis.hasVolatileRenderOrder, false, 'List items should not allocate a new order on every render'); + assert.equal(analysis.effectDependsOnRenderOrder, false, 'registration effect should not depend on render-time order'); + assert.equal(metrics.registryMutationsOnSelection, 0, 'selection changes should not unregister/register every list item'); + assert.equal(metrics.registryVersionUpdatesOnSelection, 0, 'selection changes should not publish list registry updates'); + }); +} diff --git a/scripts/test-local-model-status-probe-cache.mjs b/scripts/test-local-model-status-probe-cache.mjs new file mode 100644 index 00000000..7bb75fc1 --- /dev/null +++ b/scripts/test-local-model-status-probe-cache.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { importTs } from './lib/ts-import.mjs'; + +const { + createLocalAsrHelperStatusProbeCache, + isCacheableLocalAsrHelperStatus, +} = await importTs(path.resolve('src/main/local-model-status-probe.ts')); + +function makeStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function spinForMs(durationMs) { + const end = performance.now() + durationMs; + while (performance.now() < end) {} +} + +test('local ASR helper status cache reuses stable statuses within the TTL', () => { + let now = 1_000; + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + now: () => now, + readStatus() { + probeCalls += 1; + return makeStatus({ path: `/models/parakeet-${probeCalls}` }); + }, + }); + + assert.equal(cache.getStatus().path, '/models/parakeet-1'); + assert.equal(cache.getStatus().path, '/models/parakeet-1'); + assert.equal(probeCalls, 1); + + now += 5_001; + assert.equal(cache.getStatus().path, '/models/parakeet-2'); + assert.equal(probeCalls, 2); +}); + +test('local ASR helper status cache bypasses cached stable status when force refreshing', () => { + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + probeCalls += 1; + return makeStatus({ path: `/models/qwen3-${probeCalls}` }); + }, + }); + + assert.equal(cache.getStatus().path, '/models/qwen3-1'); + assert.equal(cache.getStatus({ forceRefresh: true }).path, '/models/qwen3-2'); + assert.equal(cache.getStatus().path, '/models/qwen3-2'); + assert.equal(probeCalls, 2); +}); + +test('local ASR helper status cache does not retain transient statuses', () => { + const statuses = [ + makeStatus({ state: 'downloading', path: '', progress: 0.5 }), + makeStatus({ state: 'error', path: '', progress: 0, error: 'failed' }), + makeStatus({ state: 'not-downloaded', path: '', progress: 0 }), + ]; + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + const status = statuses[Math.min(probeCalls, statuses.length - 1)]; + probeCalls += 1; + return status; + }, + }); + + assert.equal(cache.getStatus().state, 'downloading'); + assert.equal(cache.getStatus().state, 'error'); + assert.equal(cache.getStatus().state, 'not-downloaded'); + assert.equal(cache.getStatus().state, 'not-downloaded'); + assert.equal(probeCalls, 3); +}); + +test('local ASR helper status cache can remember a fresh post-download status', () => { + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + probeCalls += 1; + return makeStatus({ state: 'not-downloaded', path: '', progress: 0 }); + }, + }); + + assert.equal(cache.getStatus().state, 'not-downloaded'); + cache.rememberStatus(makeStatus({ state: 'downloaded', path: '/models/fresh', progress: 1 })); + + const status = cache.getStatus(); + assert.equal(status.state, 'downloaded'); + assert.equal(status.path, '/models/fresh'); + assert.equal(probeCalls, 1); +}); + +test('local ASR helper status cache measurement avoids repeated fake sync probes', () => { + const iterations = 60; + const fakeProbeMs = 1.75; + let legacyProbeCalls = 0; + const legacyStart = performance.now(); + for (let index = 0; index < iterations; index += 1) { + legacyProbeCalls += 1; + spinForMs(fakeProbeMs); + } + const legacyMs = performance.now() - legacyStart; + + let cachedProbeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + cachedProbeCalls += 1; + spinForMs(fakeProbeMs); + return makeStatus(); + }, + }); + + const cachedStart = performance.now(); + for (let index = 0; index < iterations; index += 1) { + cache.getStatus(); + } + const cachedMs = performance.now() - cachedStart; + + console.log( + `[local-model-status probe-cache] iterations=${iterations} ` + + `legacyCalls=${legacyProbeCalls} legacyMs=${legacyMs.toFixed(3)} ` + + `cachedCalls=${cachedProbeCalls} cachedMs=${cachedMs.toFixed(3)} ` + + `avoidedCalls=${legacyProbeCalls - cachedProbeCalls}` + ); + + assert.equal(legacyProbeCalls, iterations); + assert.equal(cachedProbeCalls, 1); + assert.equal(isCacheableLocalAsrHelperStatus(makeStatus()), true); + assert.ok(cachedMs < legacyMs / 4); +}); diff --git a/scripts/test-main-icon-ipc-cache.mjs b/scripts/test-main-icon-ipc-cache.mjs new file mode 100644 index 00000000..a91f8bc0 --- /dev/null +++ b/scripts/test-main-icon-ipc-cache.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { build } from 'esbuild'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/main/icon-ipc-cache.ts'); +let importNonce = 0; + +async function importMainIconCacheHarness() { + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#main-icon-cache-${importNonce++}`, + ].join(''); + return import(dataUrl); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeIcon(dataUrl, resizes = []) { + return { + isEmpty: () => false, + resize: (options) => { + resizes.push(options); + return { + toDataURL: () => dataUrl, + }; + }, + }; +} + +async function resolveFileIconUncached(getFileIcon, filePath, size = 20) { + try { + const bucket = size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal'; + const icon = await getFileIcon(filePath, { size: bucket }); + if (icon && !icon.isEmpty()) { + return icon.resize({ width: size, height: size }).toDataURL(); + } + return null; + } catch { + return null; + } +} + +async function measureFileIconCalls(api, { useCache, iterations, mode, delayMs = 1 }) { + const filePath = '/tmp/../tmp/supercmd-main-icon.txt'; + const dataUrl = 'data:image/png;base64,file'; + let nativeCalls = 0; + + const getFileIcon = async () => { + nativeCalls += 1; + await delay(delayMs); + return makeIcon(dataUrl); + }; + const cache = api.createFileIconDataUrlCache({ getFileIcon, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 20) + : (requestedPath) => resolveFileIconUncached(getFileIcon, requestedPath, 20); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(filePath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(filePath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +async function resolveAppIconUncached(resolveAppIconDataUrl, appPath, size = 32) { + try { + return resolveAppIconDataUrl(appPath, size); + } catch { + return null; + } +} + +async function measureAppIconCalls(api, { useCache, iterations, mode }) { + const appPath = '/Applications/../Applications/SuperCmd.app'; + const dataUrl = 'data:image/png;base64,app'; + let nativeCalls = 0; + + const resolveAppIconDataUrl = () => { + nativeCalls += 1; + return dataUrl; + }; + const cache = api.createAppIconDataUrlCache({ resolveAppIconDataUrl, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 32) + : (requestedPath) => resolveAppIconUncached(resolveAppIconDataUrl, requestedPath, 32); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(appPath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(appPath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +test('main icon IPC cache', async (t) => { + const api = await importMainIconCacheHarness(); + + await t.test('coalesces and caches file icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `file icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `file icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('keeps file icon cache keys normalized by path, size, and bucket', async () => { + const dataUrl = 'data:image/png;base64,file-normalized'; + const resizes = []; + const calls = []; + const cache = api.createFileIconDataUrlCache({ + maxEntries: 4, + getFileIcon: async (filePath, options) => { + calls.push({ filePath, options }); + return makeIcon(dataUrl, resizes); + }, + }); + + assert.equal(await cache.resolve('/tmp/../tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(calls.length, 1, 'normalized path alias should reuse cached data URL'); + + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 32), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 16), dataUrl); + assert.equal(calls.length, 3, 'logical size and icon bucket are part of the key'); + assert.deepEqual(calls.map((call) => call.options.size), ['normal', 'normal', 'small']); + assert.deepEqual(resizes, [ + { width: 20, height: 20 }, + { width: 32, height: 32 }, + { width: 16, height: 16 }, + ]); + }); + + await t.test('does not permanently cache file icon nulls or errors', async () => { + const recoveredDataUrl = 'data:image/png;base64,file-recovered'; + let calls = 0; + const cache = api.createFileIconDataUrlCache({ + getFileIcon: async () => { + calls += 1; + if (calls === 1) throw new Error('temporary file icon failure'); + return makeIcon(recoveredDataUrl); + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/tmp/supercmd-late-file.txt', 20), + cache.resolve('/tmp/supercmd-late-file.txt', 20), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(cache.stats().cacheSize, 1); + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + }); + + await t.test('coalesces and caches app icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `app icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `app icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('shares app icon cache with sync callers and does not permanently cache nulls', async () => { + const recoveredDataUrl = 'data:image/png;base64,app-recovered'; + let calls = 0; + const cache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath, size) => { + calls += 1; + if (calls === 1) return null; + return `${recoveredDataUrl}:${path.basename(appPath)}:${size}`; + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/Applications/SuperCmd.app', 32), + cache.resolve('/Applications/../Applications/SuperCmd.app', 32), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + const recovered = await cache.resolve('/Applications/SuperCmd.app', 32); + assert.equal(recovered, `${recoveredDataUrl}:SuperCmd.app:32`); + assert.equal(calls, 2); + assert.equal(cache.resolveSync('/Applications/../Applications/SuperCmd.app', 32), recovered); + assert.equal(calls, 2); + }); + + await t.test('bounds file and app icon caches with LRU eviction', async () => { + const fileCache = api.createFileIconDataUrlCache({ + maxEntries: 2, + getFileIcon: async (filePath) => makeIcon(`data:image/png;base64,${Buffer.from(filePath).toString('base64')}`), + }); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/b.txt', 20); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/c.txt', 20); + const fileKeys = fileCache.stats().keys.join('\n'); + assert.equal(fileCache.stats().cacheSize, 2); + assert.match(fileKeys, /\/tmp\/a\.txt/); + assert.match(fileKeys, /\/tmp\/c\.txt/); + assert.doesNotMatch(fileKeys, /\/tmp\/b\.txt/); + + const appCache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath) => `data:image/png;base64,${Buffer.from(appPath).toString('base64')}`, + }); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/B.app', 32)); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/C.app', 32)); + const appKeys = appCache.stats().keys.join('\n'); + assert.equal(appCache.stats().cacheSize, 2); + assert.match(appKeys, /\/Applications\/A\.app/); + assert.match(appKeys, /\/Applications\/C\.app/); + assert.doesNotMatch(appKeys, /\/Applications\/B\.app/); + }); +}); diff --git a/scripts/test-menubar-native-image-cache.mjs b/scripts/test-menubar-native-image-cache.mjs new file mode 100644 index 00000000..b6194a0f --- /dev/null +++ b/scripts/test-menubar-native-image-cache.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + createCachedMenuBarNativeImage, + createMenuBarNativeImageCache, +} = await import(pathToFileURL(path.join(root, 'src/main/menubar-native-image-cache.ts')).href); + +function makeCounters() { + return { + createFromBuffer: 0, + createFromDataURL: 0, + createFromPath: 0, + readFileSync: 0, + resize: 0, + setTemplateImage: 0, + statSync: 0, + }; +} + +class MockNativeImage { + constructor(counters, source, width = 32, height = 32, empty = false) { + this.counters = counters; + this.source = source; + this.width = width; + this.height = height; + this.empty = empty; + this.template = undefined; + } + + isEmpty() { + return this.empty; + } + + getSize() { + return { width: this.width, height: this.height }; + } + + resize(options) { + this.counters.resize += 1; + const resized = new MockNativeImage( + this.counters, + `${this.source}:resized:${options.width}x${options.height}`, + options.width, + options.height, + this.empty, + ); + resized.resizeQuality = options.quality; + return resized; + } + + setTemplateImage(isTemplate) { + this.counters.setTemplateImage += 1; + this.template = isTemplate; + } +} + +function makeNativeImage(counters) { + return { + createFromBuffer(_buffer, options = {}) { + counters.createFromBuffer += 1; + const logicalSize = options.scaleFactor && options.scaleFactor > 1 ? 18 : 36; + return new MockNativeImage(counters, `buffer:${options.scaleFactor || 1}`, logicalSize, logicalSize); + }, + createFromDataURL(dataUrl) { + counters.createFromDataURL += 1; + const empty = dataUrl.includes('empty-native-path-svg'); + return new MockNativeImage(counters, `data:${dataUrl.slice(0, 40)}`, 32, 32, empty); + }, + createFromPath(pathValue) { + counters.createFromPath += 1; + const empty = /\.svg$/i.test(pathValue); + return new MockNativeImage(counters, `path:${pathValue}`, 32, 32, empty); + }, + }; +} + +function makeFs(counters, files) { + return { + statSync(pathValue) { + counters.statSync += 1; + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return { + size: file.size, + mtimeMs: file.mtimeMs, + isFile: () => true, + }; + }, + readFileSync(pathValue, encoding) { + assert.equal(encoding, 'utf8'); + counters.readFileSync += 1; + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return file.body; + }, + }; +} + +function pngDataUrl(name) { + return `data:image/png;base64,${Buffer.from(`png:${name}`).toString('base64')}`; +} + +function svgDataUrl(name) { + return `data:image/svg+xml;base64,${Buffer.from(`${name}`).toString('base64')}`; +} + +function createIconResolver({ counters, files, sharedCache }) { + const nativeImage = makeNativeImage(counters); + const fs = makeFs(counters, files); + + return (icon) => createCachedMenuBarNativeImage({ + cache: sharedCache || createMenuBarNativeImageCache(512), + nativeImage, + fs, + pathValue: icon.pathValue, + dataUrlValue: icon.dataUrlValue, + bitmapScale: icon.bitmapScale, + size: icon.size, + template: icon.template, + resizeQuality: icon.resizeQuality, + }); +} + +function makeMetricFiles() { + const files = new Map(); + for (let i = 0; i < 8; i += 1) { + files.set(`/icons/path-${i}.png`, { size: 100 + i, mtimeMs: 1000 + i, body: `png-${i}` }); + } + for (let i = 0; i < 4; i += 1) { + files.set(`/icons/submenu-${i}.svg`, { + size: 200 + i, + mtimeMs: 2000 + i, + body: `empty-native-path-svg-${i}`, + }); + } + return files; +} + +function makeMetricIcons() { + const icons = [ + { + dataUrlValue: pngDataUrl('tray'), + bitmapScale: 2, + size: 18, + template: true, + resizeQuality: 'best', + }, + ]; + + for (let i = 0; i < 16; i += 1) { + icons.push({ dataUrlValue: svgDataUrl(`item-${i}`), size: 16, template: true }); + } + for (let i = 0; i < 8; i += 1) { + icons.push({ dataUrlValue: pngDataUrl(`item-${i}`), bitmapScale: 2, size: 16, template: false }); + } + for (let i = 0; i < 8; i += 1) { + icons.push({ pathValue: `/icons/path-${i}.png`, size: 16, template: false }); + } + for (let i = 0; i < 4; i += 1) { + icons.push({ pathValue: `/icons/submenu-${i}.svg`, size: 16, template: false }); + } + return icons; +} + +function workCount(counters) { + return counters.createFromBuffer + + counters.createFromDataURL + + counters.createFromPath + + counters.readFileSync + + counters.resize; +} + +function runRepeatedPayloadMetric({ updates, useSharedCache }) { + const counters = makeCounters(); + const files = makeMetricFiles(); + const sharedCache = useSharedCache ? createMenuBarNativeImageCache(512) : null; + const resolveIcon = createIconResolver({ counters, files, sharedCache }); + const icons = makeMetricIcons(); + + for (let update = 0; update < updates; update += 1) { + for (const icon of icons) { + const image = resolveIcon(icon); + assert.ok(image, 'metric icon should resolve'); + } + } + + return counters; +} + +test('MenuBarExtra native image cache', async (t) => { + await t.test('reuses data URL decode and resize work for repeated menu item icons', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ dataUrlValue: svgDataUrl('shared'), size: 16, template: true }); + const second = resolveIcon({ dataUrlValue: svgDataUrl('shared'), size: 16, template: true }); + + assert.equal(first, second, 'same source, size, scale, and template state should reuse the image'); + assert.equal(counters.createFromDataURL, 1, 'data URL is decoded once'); + assert.equal(counters.resize, 1, 'image is resized once'); + assert.equal(counters.setTemplateImage, 1, 'template state is applied once to the cached image'); + }); + + await t.test('keeps template image states isolated in the cache key', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + const dataUrlValue = svgDataUrl('template-split'); + + const templated = resolveIcon({ dataUrlValue, size: 16, template: true }); + const original = resolveIcon({ dataUrlValue, size: 16, template: false }); + + assert.notEqual(templated, original, 'template and non-template requests must not share a NativeImage object'); + assert.equal(templated.template, true); + assert.equal(original.template, false); + assert.equal(counters.createFromDataURL, 2, 'different template states decode separately'); + }); + + await t.test('preserves retina data URL handling without repeat buffer decode', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + const dataUrlValue = pngDataUrl('retina-tray'); + + const first = resolveIcon({ dataUrlValue, bitmapScale: 2, size: 18, template: true, resizeQuality: 'best' }); + const second = resolveIcon({ dataUrlValue, bitmapScale: 2, size: 18, template: true, resizeQuality: 'best' }); + + assert.equal(first, second); + assert.equal(counters.createFromBuffer, 1, 'retina PNG data URL uses createFromBuffer once'); + assert.equal(counters.createFromDataURL, 0, 'successful buffer decode does not fall back to createFromDataURL'); + assert.equal(counters.resize, 0, 'logical-size retina reps are not resized'); + }); + + await t.test('reuses stable path icons and refreshes when file identity changes', () => { + const counters = makeCounters(); + const files = new Map([ + ['/icons/stable.png', { size: 64, mtimeMs: 10, body: 'png' }], + ]); + const resolveIcon = createIconResolver({ + counters, + files, + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + const second = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + files.set('/icons/stable.png', { size: 65, mtimeMs: 11, body: 'png-new' }); + const third = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + + assert.equal(first, second, 'stable file identity should reuse decoded image'); + assert.notEqual(first, third, 'changed file identity should refresh decoded image'); + assert.equal(counters.createFromPath, 2); + assert.equal(counters.resize, 2); + assert.equal(counters.statSync, 3, 'path identity is checked each call so changed assets refresh'); + }); + + await t.test('caches SVG fallback reads for stable path icons', () => { + const counters = makeCounters(); + const files = new Map([ + ['/icons/vector.svg', { size: 42, mtimeMs: 100, body: 'empty-native-path-svg' }], + ]); + const resolveIcon = createIconResolver({ + counters, + files, + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ pathValue: '/icons/vector.svg', size: 16, template: false }); + const second = resolveIcon({ pathValue: '/icons/vector.svg', size: 16, template: false }); + + assert.equal(first, second); + assert.equal(counters.createFromPath, 1, 'native SVG path decode attempted once'); + assert.equal(counters.readFileSync, 1, 'SVG fallback body is read once'); + assert.equal(counters.createFromDataURL, 1, 'SVG fallback data URL is decoded once'); + }); + + await t.test('measures repeated accepted payload decode and resize savings', () => { + const updates = 20; + const before = runRepeatedPayloadMetric({ updates, useSharedCache: false }); + const after = runRepeatedPayloadMetric({ updates, useSharedCache: true }); + const beforeWork = workCount(before); + const afterWork = workCount(after); + + t.diagnostic( + `repeated payload (${updates} updates, 1 tray icon, 36 item/submenu icons): ` + + `before=${beforeWork} native decode/read/resize operations, after=${afterWork}`, + ); + t.diagnostic(`before=${JSON.stringify(before)} after=${JSON.stringify(after)}`); + + assert.equal(beforeWork, 1620); + assert.equal(afterWork, 81); + assert.ok(afterWork < beforeWork / 10, 'cached path should eliminate repeated native image work'); + }); +}); diff --git a/scripts/test-menubar-native-update-cache.mjs b/scripts/test-menubar-native-update-cache.mjs new file mode 100644 index 00000000..4443ebc9 --- /dev/null +++ b/scripts/test-menubar-native-update-cache.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +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 { + createMenuBarNativeUpdateState, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, +} = await importTs(path.join(root, 'src/main/menubar-native-update-cache.ts')); + +function makeItems(overrides = {}) { + return [ + { + type: 'label', + title: 'Timer', + }, + { + type: 'submenu', + id: '__mbi_group', + title: 'Controls', + children: [ + { + type: 'item', + id: '__mbi_pause', + title: 'Pause', + subtitle: 'Current session', + disabled: false, + alternate: { + id: '__mbi_pause_alt', + title: 'Pause without alert', + }, + ...overrides, + }, + ], + }, + { + type: 'separator', + }, + { + type: 'item', + id: '__mbi_stop', + title: 'Stop', + disabled: true, + }, + ]; +} + +function makePayload(overrides = {}) { + return { + extId: 'demo/timer', + iconPath: undefined, + iconDataUrl: undefined, + iconEmoji: '*', + iconTemplate: undefined, + iconBitmapScale: undefined, + fallbackIconDataUrl: '', + title: 'Timer', + tooltip: 'Timer status', + items: makeItems(), + ...overrides, + }; +} + +function makeCounters() { + return { + createTray: 0, + resolveTrayIcon: 0, + setImage: 0, + setTitle: 0, + setToolTip: 0, + buildMenuBarTemplate: 0, + buildFromTemplate: 0, + setContextMenu: 0, + }; +} + +function runUncachedNativeWork(payloads) { + const counters = makeCounters(); + let trayExists = false; + + for (const payload of payloads) { + counters.resolveTrayIcon += 1; + if (!trayExists) { + counters.createTray += 1; + trayExists = true; + } else { + counters.setImage += 1; + } + + counters.setTitle += 1; + if (payload.tooltip) counters.setToolTip += 1; + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + } + + return counters; +} + +function runCachedNativeWork(payloads, { iconResolvesOk = true } = {}) { + const counters = makeCounters(); + const state = createMenuBarNativeUpdateState(); + let trayExists = false; + + for (const payload of payloads) { + if (!trayExists) { + counters.resolveTrayIcon += 1; + counters.createTray += 1; + trayExists = true; + rememberMenuBarNativeIcon(state, payload, iconResolvesOk); + } else if (isMenuBarNativeIconRefreshNeeded(state, payload)) { + counters.resolveTrayIcon += 1; + counters.setImage += 1; + rememberMenuBarNativeIcon(state, payload, iconResolvesOk); + } + + const nextTitle = getMenuBarNativeTitle(payload, state.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(state, nextTitle)) { + counters.setTitle += 1; + rememberMenuBarNativeTitle(state, nextTitle); + } + + const nextTooltip = getMenuBarNativeTooltip(payload); + if (isMenuBarNativeTooltipUpdateNeeded(state, nextTooltip)) { + counters.setToolTip += 1; + rememberMenuBarNativeTooltip(state, nextTooltip); + } + + const nextItemsKey = getMenuBarNativeItemsKey(payload.items); + if (isMenuBarNativeMenuUpdateNeeded(state, nextItemsKey)) { + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + rememberMenuBarNativeMenu(state, nextItemsKey); + } + } + + return counters; +} + +test('MenuBarExtra native update cache', async (t) => { + await t.test('rebuilds stable native menus once across 60 title ticks', () => { + const payloads = Array.from({ length: 60 }, (_, index) => makePayload({ + title: `Timer ${String(index).padStart(2, '0')}`, + })); + const before = runUncachedNativeWork(payloads); + const after = runCachedNativeWork(payloads); + + t.diagnostic( + `60 title ticks with unchanged items: before=${before.buildFromTemplate} Menu.buildFromTemplate calls, ` + + `after=${after.buildFromTemplate}`, + ); + t.diagnostic( + `60 title ticks with unchanged items: before=${before.setContextMenu} setContextMenu calls, ` + + `after=${after.setContextMenu}`, + ); + + assert.equal(before.buildFromTemplate, 60); + assert.equal(before.setContextMenu, 60); + assert.equal(after.buildFromTemplate, 1); + assert.equal(after.setContextMenu, 1); + assert.equal(after.setTitle, 60, 'changing title text still updates the native title every tick'); + assert.equal(after.setToolTip, 1, 'stable tooltip is applied once'); + assert.equal(after.setImage, 0, 'stable non-file icon payload does not call setImage after tray creation'); + }); + + await t.test('rebuilds when serialized item payloads change', () => { + const payloads = [ + makePayload(), + makePayload({ title: 'Timer tick' }), + makePayload({ + title: 'Timer tick 2', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + makePayload({ + title: 'Timer tick 3', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + ]; + const after = runCachedNativeWork(payloads); + + assert.equal(after.buildFromTemplate, 2, 'initial menu and changed serialized items rebuild'); + assert.equal(after.setContextMenu, 2); + }); + + await t.test('keeps file-backed tray icons refreshing on accepted updates', () => { + const payloads = Array.from({ length: 3 }, (_, index) => makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + title: `Timer ${index}`, + })); + const after = runCachedNativeWork(payloads); + + assert.equal(after.resolveTrayIcon, 3, 'file-backed icons are re-resolved so file identity can refresh'); + assert.equal(after.setImage, 2, 'existing trays keep setImage behavior for file-backed icons'); + assert.equal(after.buildFromTemplate, 1); + }); + + await t.test('preserves fallback title decisions', () => { + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '+' }), false), '+'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), false), '\u23f1'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), true), ''); + }); + + await t.test('keeps empty tooltip payloads non-clearing', () => { + const state = createMenuBarNativeUpdateState(); + const initialTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: 'Timer status' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, initialTooltip), true); + rememberMenuBarNativeTooltip(state, initialTooltip); + + const emptyTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: '' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, emptyTooltip), false); + assert.equal(state.tooltip, 'Timer status'); + }); +}); diff --git a/scripts/test-menubar-payload-cache.mjs b/scripts/test-menubar-payload-cache.mjs new file mode 100644 index 00000000..a82931ab --- /dev/null +++ b/scripts/test-menubar-payload-cache.mjs @@ -0,0 +1,86 @@ +#!/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 { + createMenuBarVisiblePayloadHashCache, + shouldSendMenuBarVisiblePayload, +} = await importTs(path.join(root, 'src/renderer/src/raycast-api/menubar-runtime-payload-cache.ts')); + +function makePayload(overrides = {}) { + return { + extId: 'demo/timer', + iconPath: undefined, + iconDataUrl: undefined, + iconEmoji: '*', + iconTemplate: undefined, + iconBitmapScale: undefined, + fallbackIconDataUrl: '', + title: 'Timer', + tooltip: 'Timer status', + items: [ + { + type: 'item', + id: '__mbi_1', + title: 'Start', + subtitle: 'Ready', + tooltip: 'Start timer', + disabled: false, + }, + ], + ...overrides, + }; +} + +test('MenuBarExtra visible payload cache', async (t) => { + await t.test('skips equivalent renderer sends while action maps stay current', () => { + const cache = createMenuBarVisiblePayloadHashCache(); + let updateSends = 0; + let actionMapUpdates = 0; + + for (let i = 0; i < 3; i += 1) { + const actions = new Map([['__mbi_1', () => i]]); + actionMapUpdates += actions.size; + if (shouldSendMenuBarVisiblePayload(cache, makePayload())) { + updateSends += 1; + } + } + + t.diagnostic(`equivalent registrations: before=3 sends, after=${updateSends} send`); + assert.equal(updateSends, 1, 'only the first equivalent visible payload crosses IPC'); + assert.equal(actionMapUpdates, 3, 'action maps still refresh for every registration pass'); + }); + + await t.test('sends again when visible tray or menu fields change', () => { + const cache = createMenuBarVisiblePayloadHashCache(); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload()), true, 'initial payload sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload()), false, 'equivalent payload is skipped'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ title: 'Timer Running' })), true, 'title change sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ iconEmoji: '>' })), true, 'icon change sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ + items: [{ type: 'item', id: '__mbi_1', title: 'Pause', disabled: false }], + })), true, 'menu item change sends'); + }); + + await t.test('parent refreshes actions before skipping unchanged visible payloads', () => { + const parentSource = fs.readFileSync( + path.join(root, 'src/renderer/src/raycast-api/menubar-runtime-parent.tsx'), + 'utf8', + ); + const actionIndex = parentSource.indexOf('setMenuBarActions(extId'); + const cacheIndex = parentSource.indexOf('shouldSendMenuBarVisiblePayload('); + const updateIndex = parentSource.indexOf('updateMenuBar?.(payload)'); + + assert.ok(actionIndex >= 0, 'parent updates the action map'); + assert.ok(cacheIndex >= 0, 'parent checks the visible payload cache'); + assert.ok(updateIndex >= 0, 'parent sends the cached payload object'); + assert.ok(actionIndex < cacheIndex, 'actions update before unchanged payloads are skipped'); + assert.ok(cacheIndex < updateIndex, 'IPC send happens only after the cache check'); + }); +}); diff --git a/scripts/test-menubar-storage-remounts.mjs b/scripts/test-menubar-storage-remounts.mjs new file mode 100644 index 00000000..85cd259f --- /dev/null +++ b/scripts/test-menubar-storage-remounts.mjs @@ -0,0 +1,329 @@ +#!/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'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function depsChanged(prevDeps, nextDeps) { + if (!prevDeps || !nextDeps || prevDeps.length !== nextDeps.length) return true; + return nextDeps.some((dep, index) => !Object.is(dep, prevDeps[index])); +} + +function createReactHookHarness() { + const hooks = []; + const effects = []; + let hookIndex = 0; + let pendingEffects = []; + + const react = { + useState(initialValue) { + const index = hookIndex++; + if (!hooks[index]) { + hooks[index] = { + value: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + hooks[index].value = + typeof nextValue === 'function' ? nextValue(hooks[index].value) : nextValue; + }; + return [hooks[index].value, setState]; + }, + + useRef(initialValue) { + const index = hookIndex++; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + + useCallback(callback, deps) { + const index = hookIndex++; + const slot = hooks[index]; + if (!slot || depsChanged(slot.deps, deps)) { + hooks[index] = { value: callback, deps }; + } + return hooks[index].value; + }, + + useEffect(effect, deps) { + const index = hookIndex++; + const slot = effects[index]; + if (!slot || depsChanged(slot.deps, deps)) { + pendingEffects.push({ index, effect, deps }); + } + }, + }; + + function flushEffects() { + const toRun = pendingEffects; + pendingEffects = []; + for (const next of toRun) { + const prev = effects[next.index]; + if (typeof prev?.cleanup === 'function') prev.cleanup(); + effects[next.index] = { + deps: next.deps, + cleanup: next.effect() || undefined, + }; + } + } + + return { + react, + render(callback) { + hookIndex = 0; + const result = callback(); + flushEffects(); + return result; + }, + cleanup() { + for (const effect of effects) { + if (typeof effect?.cleanup === 'function') effect.cleanup(); + } + }, + }; +} + +function installWindowHarness() { + const listeners = new Map(); + const removedMenuBars = []; + + const win = { + electron: { + removeMenuBar(extId) { + removedMenuBars.push(extId); + }, + onExtensionPreferencesUpdated() { + return () => {}; + }, + }, + addEventListener(type, listener) { + const next = listeners.get(type) || new Set(); + next.add(listener); + listeners.set(type, next); + }, + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); + }, + dispatchEvent(event) { + for (const listener of listeners.get(event.type) || []) { + listener(event); + } + return true; + }, + }; + + globalThis.window = win; + globalThis.CustomEvent = class TestCustomEvent { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } + }; + + return { + removedMenuBars, + restore() { + delete globalThis.window; + delete globalThis.CustomEvent; + }, + }; +} + +function loadTsModule(filePath, stubs = {}) { + const resolvedPath = path.resolve(root, filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + const localRequire = (request) => { + if (request in stubs) return stubs[request]; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return loadTsModule(path.relative(root, nextPath), stubs); + } + } + } + return require(request); + }; + + vm.runInNewContext(transpiled.outputText, { + module, + exports: module.exports, + require: localRequire, + console, + window: globalThis.window, + CustomEvent: globalThis.CustomEvent, + Date, + Math, + }, { filename: resolvedPath }); + + return module.exports; +} + +function makeBundle(extName, cmdName) { + return { + code: 'module.exports = function Command() { return null; }', + title: cmdName, + extName, + cmdName, + extensionName: extName, + commandName: cmdName, + }; +} + +function findEntry(entries, extName, cmdName) { + return entries.find((entry) => { + const entryExt = entry.bundle.extName || entry.bundle.extensionName; + const entryCmd = entry.bundle.cmdName || entry.bundle.commandName; + return entryExt === extName && entryCmd === cmdName; + }); +} + +function createMenuBarSubject() { + const windowHarness = installWindowHarness(); + const reactHarness = createReactHookHarness(); + const { useMenuBarExtensions } = loadTsModule('src/renderer/src/hooks/useMenuBarExtensions.ts', { + react: reactHarness.react, + }); + + let current = reactHarness.render(() => useMenuBarExtensions()); + + return { + get current() { + return current; + }, + rerender() { + current = reactHarness.render(() => useMenuBarExtensions()); + return current; + }, + cleanup() { + reactHarness.cleanup(); + windowHarness.restore(); + }, + }; +} + +test('menu-bar storage refresh behavior', async (t) => { + await t.test('emitted storage events keep extensionName compatibility and include command origin', () => { + const windowHarness = installWindowHarness(); + const events = []; + globalThis.window.addEventListener('sc-extension-storage-changed', (event) => { + events.push(event.detail); + }); + + const { configureStorageEvents, emitExtensionStorageChanged } = loadTsModule( + 'src/renderer/src/raycast-api/storage-events.ts' + ); + + configureStorageEvents({ + getExtensionContext: () => ({ + extensionName: 'Demo', + commandName: 'Menu', + commandMode: 'menu-bar', + }), + }); + emitExtensionStorageChanged(); + + assert.equal(events.length, 1); + assert.equal(events[0].extensionName, 'Demo'); + assert.equal(events[0].commandName, 'Menu'); + assert.equal(events[0].commandMode, 'menu-bar'); + windowHarness.restore(); + }); + + await t.test('self-originated menu-bar storage writes do not remount the writer', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Menu')); + subject.rerender(); + const before = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.ok(before, 'menu-bar command should be mounted'); + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { + extensionName: 'Demo', + commandName: 'Menu', + commandMode: 'menu-bar', + }, + }) + ); + subject.rerender(); + + const after = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.equal(after, before, 'the writer keeps its ExtensionView key'); + } finally { + subject.cleanup(); + } + }); + + await t.test('sibling menu-bar commands still remount for same-extension writes', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Writer')); + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Reader')); + subject.rerender(); + const writerBefore = findEntry(subject.current.menuBarExtensions, 'Demo', 'Writer')?.key; + const readerBefore = findEntry(subject.current.menuBarExtensions, 'Demo', 'Reader')?.key; + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { + extensionName: 'Demo', + commandName: 'Writer', + commandMode: 'menu-bar', + }, + }) + ); + subject.rerender(); + + const writerAfter = findEntry(subject.current.menuBarExtensions, 'Demo', 'Writer')?.key; + const readerAfter = findEntry(subject.current.menuBarExtensions, 'Demo', 'Reader')?.key; + assert.equal(writerAfter, writerBefore, 'writer is not remounted'); + assert.notEqual(readerAfter, readerBefore, 'sibling command is refreshed'); + } finally { + subject.cleanup(); + } + }); + + await t.test('external storage updates without origin metadata still remount every command', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Menu')); + subject.rerender(); + const before = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { extensionName: 'Demo' }, + }) + ); + subject.rerender(); + + const after = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.notEqual(after, before, 'external update refreshes the mounted command'); + } finally { + subject.cleanup(); + } + }); +}); diff --git a/scripts/test-native-helper-process-lifecycle.mjs b/scripts/test-native-helper-process-lifecycle.mjs new file mode 100644 index 00000000..5cd2d73d --- /dev/null +++ b/scripts/test-native-helper-process-lifecycle.mjs @@ -0,0 +1,243 @@ +#!/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 { EventEmitter } from 'node:events'; + +const mainPath = path.resolve('src/main/main.ts'); +const mainSource = fs.readFileSync(mainPath, 'utf8'); + +const helpers = [ + { + label: 'Parakeet', + ensure: 'ensureParakeetServer', + kill: 'killParakeetServer', + processVar: 'parakeetServerProcess', + }, + { + label: 'Qwen3', + ensure: 'ensureQwen3Server', + kill: 'killQwen3Server', + processVar: 'qwen3ServerProcess', + }, + { + label: 'Whisper.cpp', + ensure: 'ensureWhisperCppServer', + kill: 'killWhisperCppServer', + processVar: 'whisperCppServerProcess', + }, + { + label: 'Audio capturer', + ensure: 'warmAudioCapturer', + kill: 'killAudioCapturer', + processVar: 'audioCapturerProcess', + }, +]; + +function extractFunction(source, functionName) { + const start = source.indexOf(`function ${functionName}`); + assert.notEqual(start, -1, `${functionName} should exist`); + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `${functionName} should have a body`); + + 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); + } + } + + throw new Error(`Could not find end of ${functionName}`); +} + +function expectContains(haystack, needle, message) { + assert.ok(haystack.includes(needle), `${message}\nExpected to find: ${needle}`); +} + +function countOccurrences(haystack, needle) { + return haystack.split(needle).length - 1; +} + +class FakeChild extends EventEmitter { + constructor(name) { + super(); + this.name = name; + this.killed = false; + this.stdout = new EventEmitter(); + this.stderr = new EventEmitter(); + this.stdin = { + writes: [], + write: (value) => { + this.stdin.writes.push(value); + }, + }; + } + + kill() { + this.killed = true; + } +} + +function createLifecycleHarness({ guarded, label }) { + let activeProcess = null; + let ready = false; + let starting = null; + let buffer = ''; + let pendingRequest = null; + const rejections = []; + + function rejectPending(message) { + if (!pendingRequest) return; + pendingRequest.reject(new Error(message)); + pendingRequest = null; + } + + function attach(child) { + child.on('exit', (code) => { + if (guarded && activeProcess !== child) return; + ready = false; + activeProcess = null; + starting = null; + buffer = ''; + rejectPending(`${label} exited with code ${code}`); + }); + + child.stdout.on('data', (chunk) => { + if (guarded && activeProcess !== child) return; + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const json = JSON.parse(trimmed); + if (json.ready) { + ready = true; + continue; + } + if (pendingRequest) { + const req = pendingRequest; + pendingRequest = null; + req.resolve(json); + } + } + }); + } + + return { + spawn(name) { + const child = new FakeChild(name); + activeProcess = child; + ready = false; + starting = Promise.resolve(); + attach(child); + return child; + }, + kill(childToKill = activeProcess) { + if (childToKill) { + childToKill.stdin.write('{"command":"exit"}\n'); + childToKill.kill(); + } + if (guarded && childToKill && activeProcess !== childToKill) return; + activeProcess = null; + ready = false; + starting = null; + buffer = ''; + rejectPending(`${label} killed`); + }, + setPending() { + pendingRequest = { + resolve: () => {}, + reject: (error) => { + rejections.push(error.message); + }, + }; + }, + get activeProcess() { + return activeProcess; + }, + get ready() { + return ready; + }, + get starting() { + return starting; + }, + get pendingRequest() { + return pendingRequest; + }, + get rejections() { + return rejections; + }, + }; +} + +test('native helper lifecycle source uses active-child guards', () => { + for (const helper of helpers) { + const killSource = extractFunction(mainSource, helper.kill); + const ensureSource = extractFunction(mainSource, helper.ensure); + + expectContains( + killSource, + `${helper.kill}(processToKill: any = ${helper.processVar})`, + `${helper.label} kill helper should accept the child being killed` + ); + expectContains( + killSource, + `if (processToKill && ${helper.processVar} !== processToKill) return;`, + `${helper.label} kill helper should not clear replacement state for a stale child` + ); + assert.ok( + countOccurrences(ensureSource, `if (${helper.processVar} !== child) return;`) >= 2, + `${helper.label} exit and stdout handlers should ignore stale child events` + ); + expectContains( + ensureSource, + `${helper.kill}(child);`, + `${helper.label} startup timeout should only kill the child it started` + ); + expectContains( + ensureSource, + `if (${helper.processVar} !== child) {`, + `${helper.label} startup wait should detect a superseded child by identity` + ); + } +}); + +test('legacy reproduction shows why stale exits are dangerous', () => { + const harness = createLifecycleHarness({ guarded: false, label: 'legacy helper' }); + const oldChild = harness.spawn('old'); + harness.kill(oldChild); + const replacement = harness.spawn('replacement'); + harness.setPending(); + + oldChild.emit('exit', 0); + + assert.equal(harness.activeProcess, null); + assert.equal(harness.pendingRequest, null); + assert.deepEqual(harness.rejections, ['legacy helper exited with code 0']); + assert.notEqual(replacement, null); +}); + +test('guarded lifecycle preserves replacement process and pending request', () => { + for (const helper of helpers) { + const harness = createLifecycleHarness({ guarded: true, label: helper.label }); + const oldChild = harness.spawn('old'); + harness.kill(oldChild); + const replacement = harness.spawn('replacement'); + harness.setPending(); + + oldChild.emit('exit', 0); + oldChild.stdout.emit('data', Buffer.from('{"ready":true}\n')); + + assert.equal(harness.activeProcess, replacement, `${helper.label} replacement should remain active`); + assert.equal(harness.ready, false, `${helper.label} stale stdout should not mark replacement ready`); + assert.notEqual(harness.starting, null, `${helper.label} replacement startup should remain tracked`); + assert.notEqual(harness.pendingRequest, null, `${helper.label} replacement pending request should remain pending`); + assert.deepEqual(harness.rejections, [], `${helper.label} stale exit should not reject replacement request`); + } +}); diff --git a/scripts/test-notes-async-saves.mjs b/scripts/test-notes-async-saves.mjs new file mode 100644 index 00000000..b39c481e --- /dev/null +++ b/scripts/test-notes-async-saves.mjs @@ -0,0 +1,251 @@ +#!/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'); + 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-oauth-callback-queue.mjs b/scripts/test-oauth-callback-queue.mjs new file mode 100644 index 00000000..5113408f --- /dev/null +++ b/scripts/test-oauth-callback-queue.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const bridgePath = path.join(root, 'src/renderer/src/raycast-api/oauth/oauth-bridge.ts'); +let importNonce = 0; + +async function importOAuthBridge() { + const result = await build({ + entryPoints: [bridgePath], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + }); + const code = result.outputFiles[0].text; + const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}#oauth-${importNonce++}`; + return import(dataUrl); +} + +async function loadBridge(t) { + const previousWindow = globalThis.window; + let callback = null; + + globalThis.window = { + electron: { + onOAuthCallback: (handler) => { + callback = handler; + return () => { + callback = null; + }; + }, + }, + }; + + t.after(() => { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); + + const bridge = await importOAuthBridge(); + bridge.ensureOAuthCallbackBridge(); + assert.equal(typeof callback, 'function', 'OAuth callback bridge should register a callback listener'); + + return { + bridge, + emit: (url) => callback(url), + }; +} + +function callbackUrl(state, code = state) { + const url = new URL('supercmd://oauth/callback'); + url.searchParams.set('state', state); + url.searchParams.set('code', code); + return url.toString(); +} + +test('OAuth callback queue caps retained unmatched callbacks', async (t) => { + const { bridge, emit } = await loadBridge(t); + const cap = bridge.OAUTH_CALLBACK_QUEUE_MAX_SIZE; + const total = 1000; + + assert.ok(cap < total, 'test fixture should exceed the configured callback queue cap'); + + for (let i = 0; i < total; i++) { + emit(callbackUrl(`queued-${i}`)); + } + + await assert.rejects( + bridge.waitForOAuthCallback('queued-0', 1), + /OAuth authorization timed out/, + 'old unmatched callbacks should be evicted once the cap is exceeded' + ); + + const firstRetained = total - cap; + const callback = await bridge.waitForOAuthCallback(`queued-${firstRetained}`, 1); + assert.equal(callback.state, `queued-${firstRetained}`); + assert.equal(callback.code, `queued-${firstRetained}`); +}); + +test('OAuth callback queue prunes entries older than the callback timeout window', async (t) => { + let now = 1_000_000; + t.mock.method(Date, 'now', () => now); + + const { bridge, emit } = await loadBridge(t); + emit(callbackUrl('stale')); + + now += bridge.OAUTH_CALLBACK_TIMEOUT_MS + 1; + emit(callbackUrl('fresh')); + + await assert.rejects( + bridge.waitForOAuthCallback('stale', 1), + /OAuth authorization timed out/, + 'callbacks outside the timeout window should be pruned' + ); + + const callback = await bridge.waitForOAuthCallback('fresh', 1); + assert.equal(callback.state, 'fresh'); + assert.equal(callback.code, 'fresh'); +}); + +test('OAuth callback waiter resolves when a matching state arrives', async (t) => { + const { bridge, emit } = await loadBridge(t); + const pending = bridge.waitForOAuthCallback('matching-state', 100); + + emit(callbackUrl('other-state')); + emit(callbackUrl('matching-state', 'matching-code')); + + const callback = await pending; + assert.equal(callback.state, 'matching-state'); + assert.equal(callback.code, 'matching-code'); +}); + +test('OAuth callback bridge ignores malformed and non-OAuth URLs safely', async (t) => { + const { bridge, emit } = await loadBridge(t); + emit(callbackUrl('keep')); + + for (let i = 0; i < bridge.OAUTH_CALLBACK_QUEUE_MAX_SIZE + 25; i++) { + emit(i % 2 === 0 ? `not a url ${i}` : `https://example.com/oauth/callback?state=ignored-${i}`); + } + + const callback = await bridge.waitForOAuthCallback('keep', 1); + assert.equal(callback.state, 'keep'); + assert.equal(callback.code, 'keep'); + + await assert.rejects( + bridge.waitForOAuthCallback('ignored-1', 1), + /OAuth authorization timed out/, + 'non-OAuth URLs should not be retained as callbacks' + ); +}); diff --git a/scripts/test-ollama-pull-listener-cleanup.mjs b/scripts/test-ollama-pull-listener-cleanup.mjs new file mode 100644 index 00000000..bba9a4a6 --- /dev/null +++ b/scripts/test-ollama-pull-listener-cleanup.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const REMOUNT_CYCLES = 5; +const CURRENT_REQUEST_ID = 'ollama-pull-current'; +const OTHER_REQUEST_ID = 'ollama-pull-stale'; +const PREFERRED_MODEL = 'llama3.2'; + +const { + registerOllamaPullListeners, + toOllamaPullProgressState, +} = await importTs(path.resolve('src/renderer/src/settings/ollamaPullProgress.ts')); + +function createBridge({ returnCleanup }) { + const emitter = new EventEmitter(); + + function subscribe(channel, callback) { + const listener = (data) => callback(data); + emitter.on(channel, listener); + if (!returnCleanup) return undefined; + return () => emitter.removeListener(channel, listener); + } + + return { + onOllamaPullProgress: (callback) => subscribe('ollama-pull-progress', callback), + onOllamaPullDone: (callback) => subscribe('ollama-pull-done', callback), + onOllamaPullError: (callback) => subscribe('ollama-pull-error', callback), + emitProgress: (data) => emitter.emit('ollama-pull-progress', data), + emitDone: (data) => emitter.emit('ollama-pull-done', data), + emitError: (data) => emitter.emit('ollama-pull-error', data), + listenerCounts: () => ({ + progress: emitter.listenerCount('ollama-pull-progress'), + done: emitter.listenerCount('ollama-pull-done'), + error: emitter.listenerCount('ollama-pull-error'), + }), + }; +} + +function createStateRecorder() { + let activeRequestId = CURRENT_REQUEST_ID; + let preferredModel = PREFERRED_MODEL; + const metrics = { + progressWrites: 0, + progressResets: 0, + doneRefreshes: 0, + errorMessages: 0, + errorClearsScheduled: 0, + clearActivePulls: 0, + pullingModelClears: 0, + refreshedModels: [], + errors: [], + }; + + return { + get activeRequestId() { + return activeRequestId; + }, + get metrics() { + return metrics; + }, + resetActivePull() { + activeRequestId = CURRENT_REQUEST_ID; + preferredModel = PREFERRED_MODEL; + }, + optionsForFixed(bridge) { + return { + bridge, + getActiveRequestId: () => activeRequestId, + getPreferredModel: () => preferredModel, + clearActivePull: () => { + activeRequestId = null; + preferredModel = undefined; + metrics.clearActivePulls += 1; + }, + setPullingModel: (modelName) => { + if (modelName === null) metrics.pullingModelClears += 1; + }, + setPullProgress: (progress) => { + if (progress.status === '' && progress.percent === 0) { + metrics.progressResets += 1; + } else { + metrics.progressWrites += 1; + } + }, + setOllamaError: (error) => { + if (error) { + metrics.errorMessages += 1; + metrics.errors.push(error); + } + }, + scheduleErrorClear: () => { + metrics.errorClearsScheduled += 1; + }, + refreshOllamaStatus: (modelName) => { + metrics.doneRefreshes += 1; + metrics.refreshedModels.push(modelName); + }, + }; + }, + optionsForLegacy(bridge) { + return { + bridge, + getPreferredModel: () => preferredModel, + clearActivePull: () => { + activeRequestId = null; + preferredModel = undefined; + metrics.clearActivePulls += 1; + }, + setPullingModel: (modelName) => { + if (modelName === null) metrics.pullingModelClears += 1; + }, + setPullProgress: (progress) => { + if (progress.status === '' && progress.percent === 0) { + metrics.progressResets += 1; + } else { + metrics.progressWrites += 1; + } + }, + setOllamaError: (error) => { + if (error) { + metrics.errorMessages += 1; + metrics.errors.push(error); + } + }, + scheduleErrorClear: () => { + metrics.errorClearsScheduled += 1; + }, + refreshOllamaStatus: (modelName) => { + metrics.doneRefreshes += 1; + metrics.refreshedModels.push(modelName); + }, + }; + }, + }; +} + +function registerLegacyAITabPullListeners({ + bridge, + clearActivePull, + getPreferredModel, + refreshOllamaStatus, + scheduleErrorClear, + setOllamaError, + setPullingModel, + setPullProgress, +}) { + bridge.onOllamaPullProgress((data) => { + setPullProgress(toOllamaPullProgressState(data)); + }); + bridge.onOllamaPullDone(() => { + const preferredModel = getPreferredModel(); + clearActivePull(); + setPullingModel(null); + setPullProgress({ status: '', percent: 0 }); + refreshOllamaStatus(preferredModel); + }); + bridge.onOllamaPullError((data) => { + clearActivePull(); + setPullingModel(null); + setPullProgress({ status: '', percent: 0 }); + setOllamaError(data.error); + scheduleErrorClear(); + }); + return () => {}; +} + +function mountAndUnmountRepeatedly(register, bridge, state, cycles) { + for (let index = 0; index < cycles; index += 1) { + const cleanup = register(bridge, state); + cleanup(); + } + return register(bridge, state); +} + +function progressPayload(overrides = {}) { + return { + requestId: CURRENT_REQUEST_ID, + status: 'pulling manifest', + digest: 'sha256:test', + total: 100, + completed: 25, + ...overrides, + }; +} + +function runLifecycleScenario({ label, bridge, register }) { + const state = createStateRecorder(); + const cleanupActiveMount = mountAndUnmountRepeatedly(register, bridge, state, REMOUNT_CYCLES); + + bridge.emitProgress(progressPayload()); + state.resetActivePull(); + bridge.emitDone({ requestId: CURRENT_REQUEST_ID }); + state.resetActivePull(); + bridge.emitError({ requestId: CURRENT_REQUEST_ID, error: 'pull failed' }); + cleanupActiveMount(); + + const result = { + label, + remountCycles: REMOUNT_CYCLES, + metrics: state.metrics, + listenerCountsAfterCleanup: bridge.listenerCounts(), + }; + console.log(`[ollama-pull ${label}] ${JSON.stringify(result)}`); + return result; +} + +test('AITab-like remount churn does not multiply Ollama pull listeners after cleanup', () => { + const before = runLifecycleScenario({ + label: 'before-inline-listeners', + bridge: createBridge({ returnCleanup: false }), + register: (bridge, state) => registerLegacyAITabPullListeners(state.optionsForLegacy(bridge)), + }); + const after = runLifecycleScenario({ + label: 'after-cleanup-listeners', + bridge: createBridge({ returnCleanup: true }), + register: (bridge, state) => registerOllamaPullListeners(state.optionsForFixed(bridge)), + }); + + assert.deepEqual({ + progressWrites: before.metrics.progressWrites, + doneRefreshes: before.metrics.doneRefreshes, + errorMessages: before.metrics.errorMessages, + }, { + progressWrites: REMOUNT_CYCLES + 1, + doneRefreshes: REMOUNT_CYCLES + 1, + errorMessages: REMOUNT_CYCLES + 1, + }); + assert.deepEqual(before.listenerCountsAfterCleanup, { + progress: REMOUNT_CYCLES + 1, + done: REMOUNT_CYCLES + 1, + error: REMOUNT_CYCLES + 1, + }); + + assert.deepEqual({ + progressWrites: after.metrics.progressWrites, + doneRefreshes: after.metrics.doneRefreshes, + errorMessages: after.metrics.errorMessages, + }, { + progressWrites: 1, + doneRefreshes: 1, + errorMessages: 1, + }); + assert.deepEqual(after.listenerCountsAfterCleanup, { progress: 0, done: 0, error: 0 }); + assert.deepEqual(after.metrics.refreshedModels, [PREFERRED_MODEL]); +}); + +test('Ollama pull listeners ignore stale request ids and dedupe identical progress', () => { + const bridge = createBridge({ returnCleanup: true }); + const state = createStateRecorder(); + const cleanup = registerOllamaPullListeners(state.optionsForFixed(bridge)); + + bridge.emitProgress(progressPayload({ requestId: OTHER_REQUEST_ID, completed: 75 })); + bridge.emitDone({ requestId: OTHER_REQUEST_ID }); + bridge.emitError({ requestId: OTHER_REQUEST_ID, error: 'stale failure' }); + assert.deepEqual({ + progressWrites: state.metrics.progressWrites, + doneRefreshes: state.metrics.doneRefreshes, + errorMessages: state.metrics.errorMessages, + }, { + progressWrites: 0, + doneRefreshes: 0, + errorMessages: 0, + }); + + bridge.emitProgress(progressPayload({ completed: 25 })); + bridge.emitProgress(progressPayload({ completed: 25 })); + bridge.emitProgress(progressPayload({ completed: 25.1 })); + bridge.emitProgress(progressPayload({ status: 'pulling layers', completed: 25.1 })); + + assert.equal(state.metrics.progressWrites, 2); + cleanup(); + assert.deepEqual(bridge.listenerCounts(), { progress: 0, done: 0, error: 0 }); +}); diff --git a/scripts/test-package-manager-parity.mjs b/scripts/test-package-manager-parity.mjs new file mode 100644 index 00000000..0c9cee5e --- /dev/null +++ b/scripts/test-package-manager-parity.mjs @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkPackageManagerParity } from "./check-package-manager-parity.mjs"; + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function createFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "supercmd-package-manager-parity-")); + const packageJson = { + name: "fixture", + version: "1.0.0", + packageManager: "npm@11.12.1", + dependencies: { + "@raycast/api": "^1.104.5", + }, + devDependencies: { + typescript: "^5.3.3", + }, + }; + const packageLock = { + name: "fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + name: "fixture", + version: "1.0.0", + dependencies: { + "@raycast/api": "^1.104.5", + }, + devDependencies: { + typescript: "^5.3.3", + }, + }, + "node_modules/@raycast/api": { + version: "1.104.19", + }, + "node_modules/typescript": { + version: "5.9.3", + }, + }, + }; + + writeJson(path.join(rootDir, "package.json"), packageJson); + writeJson(path.join(rootDir, "package-lock.json"), packageLock); + writeJson(path.join(rootDir, "node_modules/@raycast/api/package.json"), { + name: "@raycast/api", + version: "1.104.19", + }); + writeJson(path.join(rootDir, "node_modules/typescript/package.json"), { + name: "typescript", + version: "5.9.3", + }); + + return rootDir; +} + +test("package manager parity passes for npm lockfile and matching installed packages", () => { + const rootDir = createFixture(); + + const result = checkPackageManagerParity({ + rootDir, + env: { + npm_config_user_agent: "npm/11.12.1 node/v22.22.3 darwin arm64 workspaces/false", + npm_execpath: "/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js", + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.checkedInstalledPackages, 2); +}); + +test("package manager parity blocks pnpm lifecycle execution", () => { + const rootDir = createFixture(); + + const result = checkPackageManagerParity({ + rootDir, + env: { + npm_config_user_agent: "pnpm/11.7.0 npm/? node/v22.22.3 darwin arm64", + npm_execpath: "/opt/homebrew/lib/node_modules/pnpm/bin/pnpm.cjs", + }, + }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /running under pnpm/); +}); + +test("package manager parity blocks generated root pnpm lockfiles", () => { + const rootDir = createFixture(); + fs.writeFileSync(path.join(rootDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /pnpm-lock\.yaml exists/); +}); + +test("package manager parity catches package.json and package-lock root spec drift", () => { + const rootDir = createFixture(); + const packageJsonPath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + packageJson.dependencies["@raycast/api"] = "^1.104.21"; + writeJson(packageJsonPath, packageJson); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /dependencies\.@raycast\/api/); +}); + +test("package manager parity catches installed package versions that differ from package-lock", () => { + const rootDir = createFixture(); + writeJson(path.join(rootDir, "node_modules/@raycast/api/package.json"), { + name: "@raycast/api", + version: "1.104.21", + }); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /@raycast\/api is installed at 1\.104\.21/); +}); diff --git a/scripts/test-raycast-ai-abort-listeners.mjs b/scripts/test-raycast-ai-abort-listeners.mjs new file mode 100644 index 00000000..7e4e3dd7 --- /dev/null +++ b/scripts/test-raycast-ai-abort-listeners.mjs @@ -0,0 +1,203 @@ +#!/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 { transform } from 'esbuild'; + +const RAYCAST_API_PATH = path.resolve('src/renderer/src/raycast-api/index.tsx'); + +test('AI.ask detaches caller abort listeners on every terminal path', async (t) => { + await t.test('stream success', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('success prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + electron.handlers.chunk({ requestId, chunk: 'hello ' }); + electron.handlers.chunk({ requestId, chunk: 'world' }); + electron.handlers.done({ requestId }); + + assert.equal(await stream, 'hello world'); + assertDetached('success', tracked); + }); + + await t.test('stream error', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('error prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + electron.handlers.error({ requestId, error: 'stream failed' }); + + await assert.rejects(stream, /stream failed/); + assertDetached('stream error', tracked); + }); + + await t.test('aiAsk rejection', async () => { + const { AI } = await loadRaycastAIShim({ rejectAiAsk: true }); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('ipc rejection prompt', { signal: tracked.signal }); + + await assert.rejects(stream, /aiAsk rejected/); + assertDetached('aiAsk rejection', tracked); + }); + + await t.test('explicit abort', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('abort prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + tracked.controller.abort(); + + await assert.rejects(stream, /Request aborted/); + assert.deepEqual(electron.cancellations, [requestId]); + assertDetached('explicit abort', tracked); + }); +}); + +function assertDetached(label, tracked) { + const stats = tracked.stats(); + console.log( + `AI.ask abort listeners after ${label}: active=${stats.active}, added=${stats.added}, removed=${stats.removed}` + ); + assert.equal(stats.active, 0, `${label} should not retain caller abort listeners`); + assert.equal(stats.added, 1, `${label} should attach one caller abort listener`); + assert.equal(stats.removed, 1, `${label} should detach the caller abort listener`); +} + +async function loadRaycastAIShim(options = {}) { + const electron = createFakeElectron(options); + const importNonce = `${Date.now()}-${Math.random()}`; + const testWindow = { + electron, + addEventListener() {}, + removeEventListener() {}, + }; + const testDocument = { + visibilityState: 'visible', + addEventListener() {}, + removeEventListener() {}, + }; + + globalThis.window = testWindow; + globalThis.document = testDocument; + + const source = fs.readFileSync(RAYCAST_API_PATH, 'utf8'); + const start = source.indexOf('type AICreativity ='); + const aiExport = source.indexOf('export const AI =', start); + const end = source.indexOf('// =====================================================================', aiExport); + + assert.notEqual(start, -1, 'expected to find AI section start'); + assert.notEqual(aiExport, -1, 'expected to find AI export'); + assert.notEqual(end, -1, 'expected to find AI section end'); + + const moduleSource = ` +async function refreshAIAvailabilityCache() {} +${source.slice(start, end)} +export const __testImportNonce = ${JSON.stringify(importNonce)}; +`; + + const { code } = await transform(moduleSource, { + loader: 'tsx', + format: 'esm', + target: 'es2020', + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + const module = await import(dataUrl); + return { AI: module.AI, electron }; +} + +function createFakeElectron(options = {}) { + const handlers = { + chunk: undefined, + done: undefined, + error: undefined, + }; + const aiAskCalls = []; + const cancellations = []; + + return { + handlers, + aiAskCalls, + cancellations, + onAIStreamChunk(handler) { + handlers.chunk = handler; + return () => { + if (handlers.chunk === handler) handlers.chunk = undefined; + }; + }, + onAIStreamDone(handler) { + handlers.done = handler; + return () => { + if (handlers.done === handler) handlers.done = undefined; + }; + }, + onAIStreamError(handler) { + handlers.error = handler; + return () => { + if (handlers.error === handler) handlers.error = undefined; + }; + }, + aiAsk(requestId, prompt, askOptions) { + aiAskCalls.push({ requestId, prompt, askOptions }); + if (options.rejectAiAsk) { + return Promise.reject(new Error('aiAsk rejected')); + } + return Promise.resolve(); + }, + aiCancel(requestId) { + cancellations.push(requestId); + return Promise.resolve(); + }, + }; +} + +function createTrackedAbortController() { + const controller = new AbortController(); + const signal = controller.signal; + const originalAddEventListener = signal.addEventListener.bind(signal); + const originalRemoveEventListener = signal.removeEventListener.bind(signal); + const activeAbortListeners = new Set(); + let added = 0; + let removed = 0; + + Object.defineProperty(signal, 'addEventListener', { + configurable: true, + value(type, listener, options) { + if (type === 'abort') { + activeAbortListeners.add(listener); + added += 1; + } + return originalAddEventListener(type, listener, options); + }, + }); + + Object.defineProperty(signal, 'removeEventListener', { + configurable: true, + value(type, listener, options) { + if (type === 'abort' && activeAbortListeners.delete(listener)) { + removed += 1; + } + return originalRemoveEventListener(type, listener, options); + }, + }); + + return { + controller, + signal, + stats() { + return { + active: activeAbortListeners.size, + added, + removed, + }; + }, + }; +} diff --git a/scripts/test-raycast-cache-size-index.mjs b/scripts/test-raycast-cache-size-index.mjs new file mode 100644 index 00000000..5272ba39 --- /dev/null +++ b/scripts/test-raycast-cache-size-index.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const CACHE_SOURCE_FILE = 'src/renderer/src/raycast-api/index.tsx'; +const ENTRY_COUNT = 120; +const ENTRY_SIZE = 1024; + +function extractCacheSource() { + const source = fs.readFileSync(CACHE_SOURCE_FILE, 'utf8'); + const start = source.indexOf('export namespace Cache'); + const end = source.indexOf('// =====================================================================\n// \u2500\u2500\u2500 AI', start); + assert.notEqual(start, -1, 'Cache namespace marker should exist'); + assert.notEqual(end, -1, 'AI section marker should exist after Cache'); + return source.slice(start, end); +} + +const transpiledCache = ts.transpileModule(extractCacheSource(), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: CACHE_SOURCE_FILE, +}); + +function createInstrumentedLocalStorage() { + const data = new Map(); + const stats = { + getItem: 0, + setItem: 0, + removeItem: 0, + key: 0, + length: 0, + }; + + return { + getItem(key) { + stats.getItem += 1; + const normalizedKey = String(key); + return data.has(normalizedKey) ? data.get(normalizedKey) : null; + }, + setItem(key, value) { + stats.setItem += 1; + data.set(String(key), String(value)); + }, + removeItem(key) { + stats.removeItem += 1; + data.delete(String(key)); + }, + key(index) { + stats.key += 1; + return Array.from(data.keys())[index] ?? null; + }, + get length() { + stats.length += 1; + return data.size; + }, + clear() { + data.clear(); + }, + resetStats() { + stats.getItem = 0; + stats.setItem = 0; + stats.removeItem = 0; + stats.key = 0; + stats.length = 0; + }, + snapshotStats() { + return { ...stats }; + }, + rawValue(key) { + return data.get(String(key)); + }, + }; +} + +function loadCache(storage, sandboxConsole = console) { + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + console: sandboxConsole, + localStorage: storage, + JSON, + Map, + Set, + }; + + vm.runInNewContext(transpiledCache.outputText, sandbox, { filename: CACHE_SOURCE_FILE }); + return module.exports.Cache; +} + +function measure(storage, name, fn) { + storage.resetStats(); + const startedAt = performance.now(); + const result = fn(); + const durationMs = performance.now() - startedAt; + return { + name, + durationMs: Number(durationMs.toFixed(3)), + ...storage.snapshotStats(), + ...result, + }; +} + +function payload(size = ENTRY_SIZE, char = 'x') { + return char.repeat(size); +} + +function cacheStorageKey(namespace) { + return `sc-cache-${namespace}`; +} + +function cacheItemStorageKey(namespace, key) { + return `${cacheStorageKey(namespace)}-item-${key}`; +} + +function readMetadata(storage, namespace) { + return JSON.parse(storage.rawValue(cacheStorageKey(namespace))); +} + +function runBenchmark() { + const setStorage = createInstrumentedLocalStorage(); + const SetCache = loadCache(setStorage); + const setCache = new SetCache({ namespace: 'bench-set', capacity: ENTRY_COUNT * ENTRY_SIZE * 10 }); + const setReport = measure(setStorage, 'set-fill', () => { + for (let index = 0; index < ENTRY_COUNT; index += 1) { + setCache.set(`key-${index}`, payload()); + } + return { entries: ENTRY_COUNT }; + }); + + const getReport = measure(setStorage, 'get-existing', () => { + for (let index = 0; index < ENTRY_COUNT; index += 1) { + assert.equal(setCache.get(`key-${index}`), payload()); + } + return { entries: ENTRY_COUNT }; + }); + + const evictionStorage = createInstrumentedLocalStorage(); + const EvictionCache = loadCache(evictionStorage); + const seedCache = new EvictionCache({ + namespace: 'bench-eviction', + capacity: ENTRY_COUNT * ENTRY_SIZE * 10, + }); + for (let index = 0; index < ENTRY_COUNT; index += 1) { + seedCache.set(`key-${index}`, payload()); + } + + const evictingCache = new EvictionCache({ + namespace: 'bench-eviction', + capacity: Math.floor(ENTRY_COUNT / 2) * ENTRY_SIZE, + }); + const evictionReport = measure(evictionStorage, 'set-with-eviction', () => { + evictingCache.set('new-key', payload()); + return { entries: ENTRY_COUNT, targetCapacityEntries: Math.floor(ENTRY_COUNT / 2) }; + }); + + return [setReport, getReport, evictionReport]; +} + +function printBenchmarkReport(reports) { + console.log('Raycast Cache localStorage benchmark'); + for (const report of reports) { + console.log( + [ + ` ${report.name}`, + `entries=${report.entries}`, + `durationMs=${report.durationMs}`, + `getItem=${report.getItem}`, + `setItem=${report.setItem}`, + `removeItem=${report.removeItem}`, + `key=${report.key}`, + `length=${report.length}`, + ].join(' ') + ); + } +} + +test('Cache stores, removes, clears, and notifies subscribers', () => { + const storage = createInstrumentedLocalStorage(); + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'behavior', capacity: 4096 }); + const notifications = []; + cache.subscribe((key, data) => notifications.push([key, data])); + + cache.set('alpha', 'one'); + assert.equal(cache.get('alpha'), 'one'); + assert.equal(cache.has('alpha'), true); + assert.equal(cache.remove('alpha'), true); + assert.equal(cache.remove('alpha'), false); + assert.equal(cache.has('alpha'), false); + + cache.set('beta', 'two'); + cache.set('gamma', 'three'); + assert.equal(cache.isEmpty, false); + cache.clear(); + assert.equal(cache.isEmpty, true); + assert.equal(cache.has('beta'), false); + assert.equal(cache.has('gamma'), false); + assert.deepEqual(notifications, [ + ['alpha', 'one'], + ['alpha', undefined], + ['beta', 'two'], + ['gamma', 'three'], + [undefined, undefined], + ]); +}); + +test('Cache eviction honors least-recently-used ordering', () => { + const storage = createInstrumentedLocalStorage(); + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'lru', capacity: 9 }); + + cache.set('a', '111'); + cache.set('b', '222'); + cache.set('c', '333'); + assert.equal(cache.get('a'), '111'); + cache.set('d', '444'); + + assert.equal(cache.has('a'), true); + assert.equal(cache.has('b'), false); + assert.equal(cache.has('c'), true); + assert.equal(cache.has('d'), true); +}); + +test('Cache migrates legacy LRU metadata into size metadata', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('legacy'), JSON.stringify({ lruOrder: ['alpha', 'beta'] })); + storage.setItem(cacheItemStorageKey('legacy', 'alpha'), '111'); + storage.setItem(cacheItemStorageKey('legacy', 'beta'), '2222'); + storage.resetStats(); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'legacy', capacity: 4096 }); + const metadata = readMetadata(storage, 'legacy'); + + assert.deepEqual(metadata.lruOrder, ['alpha', 'beta']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3, beta: 4 }); + assert.equal(metadata.totalSize, 7); + assert.equal(cache.get('alpha'), '111'); + assert.equal(cache.get('beta'), '2222'); +}); + +test('Cache recovers corrupt metadata by scanning cache item keys', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('corrupt'), '{not valid json'); + storage.setItem(cacheItemStorageKey('corrupt', 'alpha'), 'aaa'); + storage.setItem(cacheItemStorageKey('corrupt', 'beta'), 'bbbb'); + storage.resetStats(); + + const errors = []; + const Cache = loadCache(storage, { + ...console, + error: (...args) => errors.push(args), + }); + const cache = new Cache({ namespace: 'corrupt', capacity: 4096 }); + const metadata = readMetadata(storage, 'corrupt'); + + assert.equal(errors.length, 1); + assert.deepEqual(metadata.lruOrder, ['alpha', 'beta']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3, beta: 4 }); + assert.equal(metadata.totalSize, 7); + assert.equal(cache.get('alpha'), 'aaa'); + assert.equal(cache.get('beta'), 'bbbb'); +}); + +test('Cache recovers malformed LRU metadata by scanning cache item keys', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('malformed'), JSON.stringify({ lruOrder: [42], sizeByKey: {} })); + storage.setItem(cacheItemStorageKey('malformed', 'alpha'), 'aaa'); + storage.resetStats(); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'malformed', capacity: 4096 }); + const metadata = readMetadata(storage, 'malformed'); + + assert.deepEqual(metadata.lruOrder, ['alpha']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3 }); + assert.equal(metadata.totalSize, 3); + assert.equal(cache.get('alpha'), 'aaa'); +}); + +test('Cache repairs stale size metadata for individual key operations', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('stale'), JSON.stringify({ + version: 2, + lruOrder: ['missing', 'external'], + sizeByKey: { missing: 10, external: 1 }, + totalSize: 11, + })); + storage.setItem(cacheItemStorageKey('stale', 'external'), 'actual'); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'stale', capacity: 4096 }); + + assert.equal(cache.has('missing'), false); + assert.equal(cache.has('external'), true); + assert.equal(cache.remove('external'), true); + assert.equal(cache.has('external'), false); + + const metadata = readMetadata(storage, 'stale'); + assert.deepEqual(metadata.lruOrder, []); + assert.deepEqual(metadata.sizeByKey, {}); + assert.equal(metadata.totalSize, 0); +}); + +test('Cache benchmark reports localStorage reads for set, get, and eviction', () => { + const reports = runBenchmark(); + printBenchmarkReport(reports); + + const byName = Object.fromEntries(reports.map((report) => [report.name, report])); + assert.equal(byName['set-fill'].getItem, 0); + assert.equal(byName['get-existing'].getItem, ENTRY_COUNT); + assert.equal(byName['set-with-eviction'].getItem, 0); + assert.ok(byName['set-with-eviction'].removeItem > 0, 'eviction scenario should remove LRU entries'); +}); diff --git a/scripts/test-registry-dirty-checks.mjs b/scripts/test-registry-dirty-checks.mjs new file mode 100644 index 00000000..0c49124e --- /dev/null +++ b/scripts/test-registry-dirty-checks.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ITEM_COUNT = Number.parseInt(process.env.SUPERCMD_REGISTRY_DIRTY_ITEMS || '5000', 10); +const REACT_ELEMENT_TYPE = Symbol.for('react.element'); + +async function importHookModule(absPath) { + const result = await build({ + entryPoints: [absPath], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + }); + const code = result.outputFiles[0].text; + return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64')); +} + +const listHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')); +const gridHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/grid-runtime-hooks.ts')); + +function namedType(displayName) { + const component = function RuntimeElement() {}; + component.displayName = displayName; + return component; +} + +function makeElement(displayName, props = {}) { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: namedType(displayName), + key: null, + props, + }; +} + +function makeListItem(index, overrides = {}) { + const props = { + id: `list-visible-${index}`, + title: { value: `Emoji ${index}`, tooltip: `Emoji title ${index}` }, + subtitle: { value: `Subtitle ${index}`, tooltip: `Subtitle tip ${index}` }, + icon: { + source: index % 2 === 0 ? '\u{1F600}' : { light: `icon-${index}.png`, dark: `icon-${index}-dark.png` }, + tintColor: index % 3 === 0 ? 'blue' : undefined, + }, + accessories: [ + { + text: { value: `Accessory ${index}`, color: 'red' }, + icon: { source: 'Icon.Star' }, + tooltip: `Accessory tip ${index}`, + }, + { + tag: { value: `Tag ${index % 17}`, color: 'green' }, + date: new Date(1_700_000_000_000 + index * 1000).toISOString(), + }, + ], + keywords: [`emoji-${index}`, `group-${index % 25}`, 'common'], + detail: makeElement('ListItemDetail', { + markdown: `# Emoji ${index}`, + metadata: makeElement('Metadata', { children: [`meta-${index}`] }), + isLoading: false, + }), + quickLook: { name: `Emoji ${index}`, path: `/tmp/emoji-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.CopyToClipboard', { title: 'Copy', content: `emoji-${index}` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `list-${index}`, + order: overrides.order ?? index, + sectionTitle: overrides.sectionTitle ?? (index < ITEM_COUNT / 2 ? 'Smileys' : 'Symbols'), + props, + }; +} + +function makeGridItem(index, overrides = {}) { + const defaultSection = index < ITEM_COUNT / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B' }; + const props = { + id: `grid-visible-${index}`, + title: `Grid ${index}`, + subtitle: `Grid subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: 'blue', fallback: 'Icon.Image' } + : { value: `Icon.Circle.${index}`, color: index % 2 === 0 ? 'purple' : 'orange' }, + accessory: { + icon: { value: 'Icon.Star', color: 'yellow' }, + tooltip: `Pinned ${index}`, + }, + keywords: [`grid-${index}`, `bucket-${index % 20}`], + quickLook: { name: `Grid ${index}`, path: `/tmp/grid-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.Open', { title: 'Open', target: `/tmp/grid-${index}.png` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `grid-${index}`, + order: overrides.order ?? index, + section: overrides.section ?? defaultSection, + props, + }; +} + +function getReactTypeName(type) { + return String(type?.displayName || type?.name || type || ''); +} + +function isReactElement(value) { + return Boolean(value && value.$$typeof === REACT_ELEMENT_TYPE); +} + +function legacyBuildSnapshotSignature(value, seen = new WeakSet()) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (typeof value === 'function') return `fn:${value.name || 'anonymous'}`; + if (typeof value === 'symbol') return value.toString(); + + if (Array.isArray(value)) { + return `[${value.map((item) => legacyBuildSnapshotSignature(item, seen)).join(',')}]`; + } + + if (isReactElement(value)) { + return `element:${getReactTypeName(value.type)}:${legacyBuildSnapshotSignature(value.props, seen)}`; + } + + if (typeof value === 'object') { + if (seen.has(value)) return '[circular]'; + seen.add(value); + + const entries = Object.entries(value) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${legacyBuildSnapshotSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +function legacyListSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((item) => { + fullSnapshotItemWalks += 1; + const actionType = item.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + return legacyBuildSnapshotSignature({ + actionName, + accessories: item.props.accessories, + detail: item.props.detail, + icon: item.props.icon, + id: item.id, + keywords: item.props.keywords, + order: item.order, + sectionTitle: item.sectionTitle || '', + subtitle: item.props.subtitle, + title: item.props.title, + }); + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function legacyGridSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((entry) => { + fullSnapshotItemWalks += 1; + const actionType = entry.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + const section = entry.section; + return `${entry.id}:${entry.props.title || ''}:${section?.id || ''}:${section?.title || ''}:${actionName}`; + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function measureLegacySnapshot(baseItems, nextItems, snapshotFn) { + const beforeSnapshot = snapshotFn(baseItems).snapshot; + const start = performance.now(); + const after = snapshotFn(nextItems); + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: after.fullSnapshotItemWalks, + itemSignatureBuilds: 0, + versionUpdates: after.snapshot === beforeSnapshot ? 0 : 1, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function measurePerItemSignatures(baseItems, nextItems, signatureFn) { + const signatureById = new Map(baseItems.map((item) => [item.id, signatureFn(item)])); + let itemSignatureBuilds = 0; + let visibleChanges = 0; + const start = performance.now(); + for (const item of nextItems) { + itemSignatureBuilds += 1; + const nextSignature = signatureFn(item); + if (signatureById.get(item.id) !== nextSignature) { + visibleChanges += 1; + signatureById.set(item.id, nextSignature); + } + } + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: 0, + itemSignatureBuilds, + versionUpdates: visibleChanges > 0 ? 1 : 0, + visibleChanges, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function collectMetrics() { + const listBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listSingleEquivalentItems = listBaseItems.slice(); + listSingleEquivalentItems[ITEM_COUNT - 1] = makeListItem(ITEM_COUNT - 1); + const listOneTitleChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeListItem(index, { props: { title: { value: `Changed ${index}`, tooltip: `Emoji title ${index}` } } }) + : makeListItem(index) + )); + + const gridBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridSingleEquivalentItems = gridBaseItems.slice(); + gridSingleEquivalentItems[ITEM_COUNT - 1] = makeGridItem(ITEM_COUNT - 1); + const gridOneContentChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeGridItem(index, { props: { content: { source: `changed-${index}.png`, tintColor: 'red' } } }) + : makeGridItem(index) + )); + + return { + itemCount: ITEM_COUNT, + list: { + singleRecreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listSingleEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, [makeListItem(ITEM_COUNT - 1)], listHooks.buildListItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listEquivalentItems, listHooks.buildListItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(listBaseItems, listOneTitleChange, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listOneTitleChange, listHooks.buildListItemVisibleSignature), + }, + }, + grid: { + singleRecreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridSingleEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, [makeGridItem(ITEM_COUNT - 1)], gridHooks.buildGridItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridEquivalentItems, gridHooks.buildGridItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(gridBaseItems, gridOneContentChange, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridOneContentChange, gridHooks.buildGridItemVisibleSignature), + }, + }, + }; +} + +function expectSignatureChange(signatureFn, base, next, label) { + assert.notEqual(signatureFn(base), signatureFn(next), `${label} should change the visible signature`); +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify(collectMetrics(), null, 2)); +} else { + test('List item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeListItem(7); + assert.equal(listHooks.buildListItemVisibleSignature(base), listHooks.buildListItemVisibleSignature(makeListItem(7))); + + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { icon: '\u{1F680}' } }), 'icon'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { accessories: [{ text: 'Changed' }] } }), 'accessories'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { detail: makeElement('ListItemDetail', { markdown: 'Changed' }) } }), 'detail'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { sectionTitle: 'Changed section' }), 'section'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { order: 999 }), 'order'); + }); + + test('Grid item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeGridItem(9); + assert.equal(gridHooks.buildGridItemVisibleSignature(base), gridHooks.buildGridItemVisibleSignature(makeGridItem(9))); + + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { content: { source: 'changed.png' } } }), 'content'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { accessory: { tooltip: 'Changed' } } }), 'accessory'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { section: { id: 'changed-section', title: 'Changed section' } }), 'section'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { order: 999 }), 'order'); + }); + + test('Registry dirty-check metrics avoid full-registry walks for recreated equivalent props', () => { + const metrics = collectMetrics(); + + assert.equal(metrics.list.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.list.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.oneVisibleChange.before.versionUpdates, 1); + assert.equal(metrics.list.oneVisibleChange.after.versionUpdates, 1); + + assert.equal(metrics.grid.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.oneVisibleChange.after.versionUpdates, 1); + + console.log(JSON.stringify({ mode: 'registry-dirty-checks', metrics }, null, 2)); + }); +} diff --git a/scripts/test-registry-microtask-lifecycle.mjs b/scripts/test-registry-microtask-lifecycle.mjs new file mode 100644 index 00000000..6ff0197e --- /dev/null +++ b/scripts/test-registry-microtask-lifecycle.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function reactStubPlugin() { + return { + name: 'react-stub', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^react$/ }, () => ({ path: 'react-stub', namespace: 'react-stub' })); + pluginBuild.onResolve({ filter: /^react\/jsx-(dev-)?runtime$/ }, () => ({ path: 'react-jsx-runtime-stub', namespace: 'react-stub' })); + pluginBuild.onLoad({ filter: /^react-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + function runtime() { + const current = globalThis.__SUPERCMD_REACT_RUNTIME__; + if (!current) throw new Error('React hook runtime is not installed'); + return current; + } + + export const Fragment = Symbol.for('react.fragment'); + export function createContext(defaultValue) { + const context = { _currentValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + return props.children; + }; + return context; + } + export function createElement(type, props, ...children) { + return { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children } }; + } + export function isValidElement(value) { + return Boolean(value && value.$$typeof === Symbol.for('react.element')); + } + export function useCallback(callback, deps) { + return runtime().useCallback(callback, deps); + } + export function useContext(context) { + return runtime().useContext(context); + } + export function useEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useMemo(factory, deps) { + return runtime().useMemo(factory, deps); + } + export function useRef(initialValue) { + return runtime().useRef(initialValue); + } + export function useState(initialValue) { + return runtime().useState(initialValue); + } + + const React = { + Fragment, + createContext, + createElement, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + }; + export default React; + `, + })); + pluginBuild.onLoad({ filter: /^react-jsx-runtime-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + import { Fragment, createElement } from 'react'; + export { Fragment }; + export function jsx(type, props, key) { + return createElement(type, { ...(props || {}), ...(key === undefined ? {} : { key }) }); + } + export function jsxs(type, props, key) { + return jsx(type, props, key); + } + export function jsxDEV(type, props, key) { + return jsx(type, props, key); + } + `, + })); + }, + }; +} + +function menuBarStubsPlugin() { + return { + name: 'menubar-stubs', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-config$/ }, () => ({ path: 'menubar-runtime-config', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-payload-cache$/ }, () => ({ path: 'menubar-runtime-payload-cache', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-shared$/ }, () => ({ path: 'menubar-runtime-shared', namespace: 'menubar-stubs' })); + + pluginBuild.onLoad({ filter: /^menubar-runtime-config$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function getMenuBarRuntimeDeps() { + return globalThis.__SUPERCMD_MENUBAR_DEPS__; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-payload-cache$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function createMenuBarVisiblePayloadHashCache() { + return {}; + } + export function shouldSendMenuBarVisiblePayload() { + return true; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-shared$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + import { createContext } from 'react'; + export const MBRegistryContext = createContext(null); + export function initMenuBarClickListener() {} + export function removeMenuBarActions() {} + export function resetMenuBarOrderCounters() {} + export function setMenuBarActions() {} + export async function toMenuBarIconPayloadAsync() { + return undefined; + } + `, + })); + }, + }; +} + +async function importBundledModule(relativePath, plugins = []) { + const result = await build({ + entryPoints: [path.join(root, relativePath)], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + jsx: 'transform', + tsconfigRaw: { + compilerOptions: { + jsx: 'react', + }, + }, + plugins: [reactStubPlugin(), ...plugins], + }); + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); +} + +function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function createHookRuntime(label) { + const hooks = []; + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + }; + let hookIndex = 0; + let mounted = true; + + const runtime = { + counters, + render(callback) { + mounted = true; + hookIndex = 0; + return callback(); + }, + unmount() { + mounted = false; + for (let index = hooks.length - 1; index >= 0; index -= 1) { + const cleanup = hooks[index]?.cleanup; + if (typeof cleanup === 'function') { + cleanup(); + hooks[index].cleanup = undefined; + } + } + }, + useCallback(callback, deps) { + return runtime.useMemo(() => callback, deps); + }, + useContext(context) { + return context?._currentValue; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return; + if (typeof existing?.cleanup === 'function') existing.cleanup(); + const cleanup = effect(); + hooks[index] = { deps, cleanup }; + }, + useMemo(factory, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return existing.value; + const value = factory(); + hooks[index] = { deps, value }; + return value; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) { + const state = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; + const setState = (nextValue) => { + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + state.value = typeof nextValue === 'function' ? nextValue(state.value) : nextValue; + }; + hooks[index] = { state, setState }; + } + return [hooks[index].state.value, hooks[index].setState]; + }, + }; + + return runtime; +} + +function createLegacyRegistryScheduler(label) { + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + pendingCallbacks: 0, + }; + let mounted = true; + let pending = false; + + return { + counters, + schedule() { + if (pending) return; + pending = true; + counters.pendingCallbacks += 1; + queueMicrotask(() => { + pending = false; + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + }); + }, + unmount() { + mounted = false; + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function installRuntime(runtime) { + globalThis.__SUPERCMD_REACT_RUNTIME__ = runtime; +} + +test('legacy registry microtask scheduler calls setState after unmount', async () => { + const legacySurfaces = ['actions', 'list', 'grid', 'menubar'].map(createLegacyRegistryScheduler); + + for (const surface of legacySurfaces) { + surface.schedule(); + surface.unmount(); + } + await flushMicrotasks(); + + const metrics = Object.fromEntries(legacySurfaces.map((surface) => [surface.counters.label, surface.counters])); + for (const surface of legacySurfaces) { + assert.equal(surface.counters.pendingCallbacks, 1, `${surface.counters.label} should have queued one callback`); + assert.equal(surface.counters.postUnmountSetStateCalls, 1, `${surface.counters.label} legacy callback should set state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-legacy-reproduction', metrics }, null, 2)); +}); + +test('registry microtasks scheduled before unmount are suppressed by runtime hooks', async () => { + const [actionModule, listModule, gridModule, menuBarModule] = await Promise.all([ + importBundledModule('src/renderer/src/raycast-api/action-runtime-registry.tsx'), + importBundledModule('src/renderer/src/raycast-api/list-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/grid-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/menubar-runtime-parent.tsx', [menuBarStubsPlugin()]), + ]); + + const actionRuntime = createHookRuntime('actions'); + installRuntime(actionRuntime); + const actionRegistryRuntime = actionModule.createActionRegistryRuntime({ + snapshotExtensionContext: () => ({}), + withExtensionContext: (_ctx, callback) => callback(), + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'view' } }, + getFormValues: () => ({}), + Clipboard: { copy: () => {} }, + trash: () => {}, + getGlobalNavigation: () => ({ push: () => {} }), + }); + const actionHook = actionRuntime.render(() => actionRegistryRuntime.useCollectedActions()); + actionHook.registryAPI.register('action-1', { title: 'Action', execute: () => {}, order: 1 }); + actionRuntime.unmount(); + actionHook.registryAPI.register('action-after-unmount', { title: 'Late', execute: () => {}, order: 2 }); + + const listRuntime = createHookRuntime('list'); + installRuntime(listRuntime); + const listHook = listRuntime.render(() => listModule.useListRegistry()); + listHook.registryAPI.set('list-1', { props: { id: 'list-1', title: 'List' }, order: 1 }); + listRuntime.unmount(); + listHook.registryAPI.set('list-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const gridRuntime = createHookRuntime('grid'); + installRuntime(gridRuntime); + const gridHook = gridRuntime.render(() => gridModule.useGridRegistry()); + gridHook.registryAPI.set('grid-1', { props: { id: 'grid-1', title: 'Grid' }, order: 1 }); + gridRuntime.unmount(); + gridHook.registryAPI.set('grid-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const menuBarRuntime = createHookRuntime('menubar'); + globalThis.window = { electron: { removeMenuBar: () => {}, updateMenuBar: () => {} } }; + globalThis.__SUPERCMD_MENUBAR_DEPS__ = { + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'menu-bar' } }, + getExtensionContext: () => ({ extensionName: 'ext', commandName: 'cmd', assetsPath: '', commandMode: 'menu-bar' }), + setExtensionContext: () => {}, + isEmojiOrSymbol: () => false, + }; + installRuntime(menuBarRuntime); + const menuTree = menuBarRuntime.render(() => menuBarModule.MenuBarExtraComponent({ children: null, title: 'Menu' })); + const menuRegistryAPI = menuTree.props.value; + menuRegistryAPI.register({ id: 'menu-1', type: 'item', title: 'Menu', order: 1 }); + menuBarRuntime.unmount(); + menuRegistryAPI.register({ id: 'menu-after-unmount', type: 'item', title: 'Late', order: 2 }); + + await flushMicrotasks(); + + const metrics = { + actions: actionRuntime.counters, + list: listRuntime.counters, + grid: gridRuntime.counters, + menubar: menuBarRuntime.counters, + }; + for (const [label, counters] of Object.entries(metrics)) { + assert.equal(counters.setStateCalls, 0, `${label} should suppress queued post-unmount updates`); + assert.equal(counters.postUnmountSetStateCalls, 0, `${label} should not call state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-lifecycle-fixed', metrics }, null, 2)); +}); diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..46fd8eb2 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') { + return { renderQuickLinkIconGlyph: () => null }; + } + if (request.startsWith('../icons/')) { + return { __esModule: true, default: makeComponent(path.basename(request)) }; + } + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); +const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); + +const { createRootCommandScoreIndex, filterCommands, rankCommands, rankCommandsWithIndex } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 2000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 1); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 0); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +const WORDS = [ + 'Notes', + 'Clipboard', + 'Window', + 'Browser', + 'Settings', + 'Canvas', + 'Timer', + 'Schedule', + 'Project', + 'Deploy', + 'Debug', + 'GitHub', + 'Snippet', + 'Search', + 'Memory', + 'Automation', +]; + +function normalizeSearchText(value) { + return String(value || '') + .normalize('NFKD') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +function makeCommands(count) { + const commands = []; + const aliases = {}; + + for (let i = 0; i < count; i += 1) { + const primary = WORDS[i % WORDS.length]; + const secondary = WORDS[(i * 7 + 3) % WORDS.length]; + const group = i % 4 === 0 ? 'extension' : i % 4 === 1 ? 'script' : i % 4 === 2 ? 'app' : 'system'; + const command = { + id: `synthetic-command-${i}`, + title: `${primary} ${secondary} Command ${i}`, + subtitle: `${secondary} tools for Project Alpha workspace ${i % 97}`, + category: group, + path: group === 'extension' ? `extension-${i % 80}/command-${i}` : undefined, + keywords: [ + primary, + secondary, + `cmd ${i}`, + i % 9 === 0 ? 'project alpha' : 'workspace', + i % 11 === 0 ? 'gh github repo' : 'local action', + ], + alwaysOnTop: i % 997 === 0, + }; + commands.push(command); + + if (i % 5 === 0) aliases[command.id] = `${primary.toLowerCase()}-${i}`; + if (i % 37 === 0) aliases[command.id] = 'gh'; + } + + return { commands, aliases }; +} + +function rootCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.08 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.68 })), + ]; +} + +function legacyRankCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.7 })), + ]; +} + +function legacyRootCommandMatches(commands, query, aliases) { + const normalizedQuery = normalizeSearchText(query); + const ranked = commands + .map((command) => { + const alias = aliases[command.id] || ''; + const firstPass = scoreRootSearchFields(query, legacyRankCommandFields(command, alias)); + if (!firstPass.matched) return null; + return { + command, + score: firstPass.matchScore, + hasExactAliasMatch: Boolean(alias) && normalizeSearchText(alias) === normalizedQuery, + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } + if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.command.title.localeCompare(b.command.title); + }); + + return ranked + .map(({ command }) => { + const scored = scoreRootSearchFields(query, rootCommandFields(command, aliases[command.id] || '')); + assert.equal(scored.matched, true, `${command.id} lost its second-pass match for ${query}`); + return { + id: command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function optimizedRootCommandMatches(commands, query, aliases) { + return rankCommands(commands, query, aliases) + .map((entry) => { + if (typeof entry.matchKind === 'string' && typeof entry.matchScore === 'number') { + return { + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + }; + } + + const scored = scoreRootSearchFields(query, rootCommandFields(entry.command, aliases[entry.command.id] || '')); + assert.equal(scored.matched, true, `${entry.command.id} lost its optimized fallback match for ${query}`); + return { + id: entry.command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function indexedRootCommandMatches(index, query) { + return rankCommandsWithIndex(index, query) + .map((entry) => ({ + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + })); +} + +function signature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + .sort(); +} + +function assertSameRootCommandMatches(commands, aliases) { + const index = createRootCommandScoreIndex(commands, aliases); + for (const query of QUERIES) { + const legacySignature = signature(legacyRootCommandMatches(commands, query, aliases)); + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + legacySignature, + `root command matches changed for query "${query}"` + ); + assert.deepEqual( + signature(indexedRootCommandMatches(index, query)), + legacySignature, + `indexed root command matches changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +function measureSingle(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) fn(); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const start = performance.now(); + total = fn(); + samples.push(performance.now() - start); + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +const { commands, aliases } = makeCommands(COMMAND_COUNT); + +assertSameRootCommandMatches(commands, aliases); +const commandScoreIndex = createRootCommandScoreIndex(commands, aliases); + +const legacy = measure('legacy filter + two-pass command scoring', (query) => { + const filtered = filterCommands(commands, query, aliases); + const matches = legacyRootCommandMatches(commands, query, aliases); + return filtered.length + matches.length; +}); + +const optimized = measure('optimized command scoring path', (query) => { + const matches = optimizedRootCommandMatches(commands, query, aliases); + return matches.length; +}); + +const indexed = measure('indexed command scoring path', (query) => { + const matches = indexedRootCommandMatches(commandScoreIndex, query); + return matches.length; +}); + +const compile = measureSingle('command scoring index compile', () => { + return createRootCommandScoreIndex(commands, aliases).entries.length; +}); + +const optimizedSpeedup = legacy.median > 0 ? legacy.median / optimized.median : 0; +const indexedSpeedup = legacy.median > 0 ? legacy.median / indexed.median : 0; +const indexedVsOptimizedSpeedup = optimized.median > 0 ? optimized.median / indexed.median : 0; + +console.log('Root search perf harness'); +console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); +console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); +console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); +console.log(`${indexed.label}: median=${indexed.median.toFixed(2)}ms min=${indexed.min.toFixed(2)}ms max=${indexed.max.toFixed(2)}ms total=${indexed.total}`); +console.log(`${compile.label}: median=${compile.median.toFixed(2)}ms min=${compile.min.toFixed(2)}ms max=${compile.max.toFixed(2)}ms total=${compile.total}`); +console.log(`legacy-vs-optimized-speedup=${optimizedSpeedup.toFixed(2)}x`); +console.log(`legacy-vs-indexed-speedup=${indexedSpeedup.toFixed(2)}x`); +console.log(`optimized-vs-indexed-speedup=${indexedVsOptimizedSpeedup.toFixed(2)}x`); diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..0cdf5433 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; + +async function withScriptCommandRunner(t, files, { + instrumentFs = false, + mockChildProcess = false, +} = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + mockChildProcess, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function createTimerHarness() { + let nextId = 1; + const active = new Map(); + const cleared = []; + + return { + setTimeout(callback, ms) { + const id = nextId++; + active.set(id, { callback, ms }); + return id; + }, + clearTimeout(id) { + cleared.push(id); + active.delete(id); + }, + get activeCount() { + return active.size; + }, + get cleared() { + return cleared; + }, + }; +} + +function createFakeProc() { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.killCount = 0; + proc.kill = () => { + proc.killCount += 1; + }; + return proc; +} + +async function withFakeGlobalTimers(timer, callback) { + const previousSetTimeout = globalThis.setTimeout; + const previousClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = timer.setTimeout; + globalThis.clearTimeout = timer.clearTimeout; + try { + return await callback(); + } finally { + globalThis.setTimeout = previousSetTimeout; + globalThis.clearTimeout = previousClearTimeout; + } +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + await t.test('caches file icon data across invalidated discoveries and refreshes changed icons', async (t) => { + const { module: runner, scriptsDir, metrics, resetMetrics } = await withScriptCommandRunner(t, {}, { + instrumentFs: true, + }); + const iconsDir = path.join(scriptsDir, '.icons'); + const iconPath = path.join(iconsDir, 'icon.png'); + const scriptPath = path.join(scriptsDir, 'with-file-icon.sh'); + const firstIcon = Buffer.alloc(4096, 1); + const secondIcon = Buffer.alloc(8192, 2); + + fs.mkdirSync(iconsDir, { recursive: true }); + fs.writeFileSync(iconPath, firstIcon); + fs.writeFileSync(scriptPath, `#!/bin/bash +${scriptHeader({ + title: 'File Icon Command', + extra: '# @raycast.icon .icons/icon.png\n', +})} +echo ok +`, { mode: 0o755 }); + + resetMetrics(); + const [initialCommand] = runner.discoverScriptCommands(); + assert.equal(initialCommand.title, 'File Icon Command'); + assert.ok(initialCommand.iconDataUrl?.startsWith('data:image/png;base64,')); + const initialReadBytes = metrics.readFileSyncBytes; + assert.ok( + initialReadBytes >= firstIcon.byteLength, + `expected first discovery to read the icon, got ${initialReadBytes} bytes`, + ); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [cachedCommand] = runner.discoverScriptCommands(); + assert.equal(cachedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.ok( + metrics.readFileSyncBytes < firstIcon.byteLength, + `expected cached discovery to skip unchanged icon bytes, got ${metrics.readFileSyncBytes} bytes`, + ); + + fs.writeFileSync(iconPath, secondIcon); + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [changedCommand] = runner.discoverScriptCommands(); + assert.notEqual(changedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.ok( + metrics.readFileSyncBytes >= secondIcon.byteLength, + `expected changed icon to be read again, got ${metrics.readFileSyncBytes} bytes`, + ); + }); + + for (const streamName of ['stdout', 'stderr']) { + await t.test(`clears timeout handle when ${streamName} exceeds output limit`, async (t) => { + const timer = createTimerHarness(); + await withFakeGlobalTimers(timer, async () => { + const { module: runner, childProcess } = await withScriptCommandRunner(t, { + 'overflow.sh': `#!/bin/bash +${scriptHeader({ title: 'Overflow Command' })} +echo "overflow" +`, + }, { mockChildProcess: true }); + const spawned = []; + childProcess.spawn = (command, args, options) => { + const proc = createFakeProc(); + spawned.push({ command, args, options, proc }); + return proc; + }; + + const [command] = runner.discoverScriptCommands(); + const promise = runner.executeScriptCommand(command.id, undefined, 60_000); + const activeBeforeOverflow = timer.activeCount; + assert.equal(activeBeforeOverflow, 1); + assert.equal(spawned.length, 1); + + const { proc } = spawned[0]; + proc[streamName].emit('data', Buffer.alloc(MAX_OUTPUT_BYTES + 1, 'x')); + + const result = await promise; + assert.equal(result.exitCode, 1); + assert.match( + result.stderr, + streamName === 'stdout' + ? /Output exceeded 2MB limit\./ + : /Error output exceeded 2MB limit\./, + ); + assert.equal(proc.killCount, 1); + assert.equal(timer.activeCount, 0); + assert.equal(timer.cleared.length, 1); + t.diagnostic( + `${streamName} overflow timeout handles: before=${activeBeforeOverflow} after=${timer.activeCount} cleared=${timer.cleared.length}`, + ); + }); + }); + } + + await t.test('discovers metadata in large scripts', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + }); +}); 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/scripts/test-use-ai-stream-batching.mjs b/scripts/test-use-ai-stream-batching.mjs new file mode 100644 index 00000000..5d98bae0 --- /dev/null +++ b/scripts/test-use-ai-stream-batching.mjs @@ -0,0 +1,528 @@ +#!/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 { transform } from 'esbuild'; + +const CHUNK_COUNT = 600; + +let activeFrameScheduler = null; + +const testWindow = { + document: { + addEventListener() {}, + removeEventListener() {}, + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, remove() {} }), + body: { appendChild() {}, removeChild() {} }, + }, + navigator: { platform: 'test', userAgent: 'node' }, + localStorage: createTestStorage(), + sessionStorage: createTestStorage(), + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload() {} }, + requestAnimationFrame(callback) { + if (activeFrameScheduler) return activeFrameScheduler.requestFrame(callback); + return setTimeout(() => callback(Date.now()), 0); + }, + cancelAnimationFrame(handle) { + if (activeFrameScheduler) { + activeFrameScheduler.cancelFrame(handle); + return; + } + clearTimeout(handle); + }, +}; + +globalThis.window = testWindow; +globalThis.document = testWindow.document; +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: testWindow.navigator, +}); +globalThis.requestAnimationFrame = (callback) => testWindow.requestAnimationFrame(callback); +globalThis.cancelAnimationFrame = (handle) => testWindow.cancelAnimationFrame(handle); + +const REACT_HOOK_IMPORT_STUB = ` +const runtime = () => { + const current = globalThis.__supercmdReactRuntime; + if (!current) throw new Error('React hook runtime is not installed'); + return current; +}; + +const useState = (initialValue) => runtime().useState(initialValue); +const useRef = (initialValue) => runtime().useRef(initialValue); +const useCallback = (callback, deps) => runtime().useCallback(callback, deps); +const useEffect = (effect, deps) => runtime().useEffect(effect, deps); +`; + +const { + createRaycastAIStreamBatcher, + useAI, +} = await importUseAiModule(path.resolve('src/renderer/src/raycast-api/hooks/use-ai.ts')); + +function createTestStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(String(key)) ? values.get(String(key)) : null; + }, + setItem(key, value) { + values.set(String(key), String(value)); + }, + removeItem(key) { + values.delete(String(key)); + }, + clear() { + values.clear(); + }, + }; +} + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +async function importUseAiModule(absPath) { + const source = fs.readFileSync(absPath, 'utf8'); + const stubbedSource = source.replace( + "import { useCallback, useEffect, useRef, useState } from 'react';", + REACT_HOOK_IMPORT_STUB + ); + + assert.notEqual(stubbedSource, source, 'expected use-ai.ts React hook import to be stubbed'); + + const { code } = await transform(stubbedSource, { + loader: 'ts', + format: 'esm', + target: 'es2020', + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + return import(dataUrl); +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) callback(Date.now()); + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createFakeRaycastAI(chunks, options = {}) { + const fullText = options.fullText ?? chunks.join(''); + const requests = []; + + return { + requests, + ask(prompt, askOptions = {}) { + const dataHandlers = []; + const signal = askOptions.signal; + let settled = false; + let resolvePromise; + let rejectPromise; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const request = { + prompt, + askOptions, + promise, + get settled() { + return settled; + }, + get signal() { + return signal; + }, + emitChunks(nextChunks = chunks) { + for (const chunk of nextChunks) { + if (signal?.aborted) return; + for (const handler of dataHandlers) handler(chunk); + } + }, + resolve(text = fullText) { + if (settled) return; + settled = true; + resolvePromise(text); + }, + reject(error) { + if (settled) return; + settled = true; + rejectPromise(error); + }, + complete(nextChunks = chunks, text = fullText) { + this.emitChunks(nextChunks); + if (!signal?.aborted) this.resolve(text); + }, + }; + + signal?.addEventListener('abort', () => { + request.reject(new Error('aborted')); + }, { once: true }); + + requests.push(request); + + const streamPromise = { + on(event, handler) { + if (event === 'data') dataHandlers.push(handler); + return streamPromise; + }, + then(onFulfilled, onRejected) { + return promise.then(onFulfilled, onRejected); + }, + catch(onRejected) { + return promise.catch(onRejected); + }, + finally(onFinally) { + return promise.finally(onFinally); + }, + }; + + if (options.autoComplete) { + queueMicrotask(() => request.complete()); + } + + return streamPromise; + }, + }; +} + +function depsChanged(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps) return true; + if (previousDeps.length !== nextDeps.length) return true; + return previousDeps.some((value, index) => !Object.is(value, nextDeps[index])); +} + +function createHookRunner(renderHook, initialArgs) { + let args = initialArgs; + let hookIndex = 0; + let mounted = false; + let pendingRender = false; + let result; + let renderCount = 0; + let stateUpdateCount = 0; + let dataVisibleUpdateCount = 0; + let hasDataSnapshot = false; + let lastDataSnapshot; + const hooks = []; + + const runtime = { + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) { + hooks[index] = { + value: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + + const setState = (nextValueOrUpdater) => { + const currentValue = hooks[index].value; + const nextValue = typeof nextValueOrUpdater === 'function' + ? nextValueOrUpdater(currentValue) + : nextValueOrUpdater; + + if (Object.is(currentValue, nextValue)) return; + + hooks[index].value = nextValue; + stateUpdateCount += 1; + if (mounted) pendingRender = true; + }; + + return [hooks[index].value, setState]; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useCallback(callback, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + if (previous && !depsChanged(previous.deps, deps)) { + return previous.callback; + } + + hooks[index] = { callback, deps }; + return callback; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + hooks[index] = { + cleanup: previous?.cleanup, + deps, + effect, + pending: !previous || depsChanged(previous.deps, deps), + }; + }, + }; + + const render = () => { + hookIndex = 0; + globalThis.__supercmdReactRuntime = runtime; + result = renderHook(...args); + renderCount += 1; + + if (result && typeof result === 'object' && 'data' in result) { + if (hasDataSnapshot && result.data !== lastDataSnapshot) { + dataVisibleUpdateCount += 1; + } + hasDataSnapshot = true; + lastDataSnapshot = result.data; + } + + for (const hook of hooks) { + if (!hook?.pending) continue; + hook.pending = false; + if (typeof hook.cleanup === 'function') hook.cleanup(); + const cleanup = hook.effect(); + hook.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + }; + + const flush = () => { + while (pendingRender) { + pendingRender = false; + render(); + } + }; + + return { + mount() { + mounted = true; + render(); + flush(); + return result; + }, + rerender(nextArgs = args) { + args = nextArgs; + pendingRender = true; + flush(); + return result; + }, + flush, + unmount() { + mounted = false; + for (const hook of hooks) { + if (typeof hook?.cleanup === 'function') { + hook.cleanup(); + hook.cleanup = undefined; + } + } + }, + get result() { + return result; + }, + get renderCount() { + return renderCount; + }, + get stateUpdateCount() { + return stateUpdateCount; + }, + get dataVisibleUpdateCount() { + return dataVisibleUpdateCount; + }, + }; +} + +async function drainMicrotasks(turns = 4) { + for (let index = 0; index < turns; index += 1) { + await Promise.resolve(); + } +} + +test('baseline fake Raycast useAI streaming updates visible state once per chunk', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + let visibleData = ''; + let visibleUpdateCount = 0; + + testWindow.__supercmdRaycastAI = ai; + const stream = testWindow.__supercmdRaycastAI.ask('baseline prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => { + visibleData += chunk; + visibleUpdateCount += 1; + }); + + ai.requests[0].complete(); + const finalData = await ai.requests[0].promise; + + assert.equal(finalData, expectedData); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('Raycast useAI stream batcher coalesces many chunks into one visible frame update', () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + const scheduler = createManualFrameScheduler(); + const dataRef = { current: '' }; + let visibleData = ''; + let visibleUpdateCount = 0; + const batcher = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData(nextData) { + visibleData = nextData; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + const stream = ai.ask('batched prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => batcher.appendChunk(chunk)); + ai.requests[0].emitChunks(); + + assert.equal(dataRef.current, expectedData); + assert.equal(visibleData, ''); + assert.equal(visibleUpdateCount, 0); + assert.equal(scheduler.pendingFrameCount(), 1); + + assert.equal(scheduler.flushFrames(), 1); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, 1); + assert.ok(visibleUpdateCount < CHUNK_COUNT / 10); + console.log(`batched useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('useAI flushes complete final data and preserves final onData behavior', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks, { autoComplete: true }); + const onDataCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'final prompt', + { + stream: true, + onData: (data) => onDataCalls.push(data), + }, + ]); + + runner.mount(); + assert.equal(ai.requests.length, 1); + + await ai.requests[0].promise; + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error, undefined); + assert.deepEqual(onDataCalls, [expectedData]); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(runner.dataVisibleUpdateCount, 1); + assert.ok(runner.renderCount < CHUNK_COUNT / 10); + console.log(`hook useAI renders: ${runner.renderCount}; visible data updates: ${runner.dataVisibleUpdateCount} for ${CHUNK_COUNT} chunks`); + + activeFrameScheduler = null; +}); + +test('useAI flushes pending streamed data before surfacing an error', async () => { + const chunks = ['partial ', 'answer']; + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + const onErrorCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'error prompt', + { + stream: true, + onError: (error) => onErrorCalls.push(error), + }, + ]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + assert.equal(scheduler.pendingFrameCount(), 1); + + request.reject(new Error('network failed')); + await request.promise.catch(() => {}); + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error?.message, 'network failed'); + assert.equal(onErrorCalls.length, 1); + assert.equal(onErrorCalls[0].message, 'network failed'); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + + activeFrameScheduler = null; +}); + +test('useAI abort cancels a pending stream flush', async () => { + const chunks = ['stale partial']; + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, ['abort prompt', { stream: true }]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + + assert.equal(runner.result.data, ''); + assert.equal(scheduler.pendingFrameCount(), 1); + + runner.unmount(); + await request.promise.catch(() => {}); + await drainMicrotasks(); + + assert.equal(request.signal.aborted, true); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + assert.equal(runner.result.data, ''); + + activeFrameScheduler = null; +}); diff --git a/scripts/test-use-fetch-stale-runs.mjs b/scripts/test-use-fetch-stale-runs.mjs new file mode 100644 index 00000000..77166639 --- /dev/null +++ b/scripts/test-use-fetch-stale-runs.mjs @@ -0,0 +1,322 @@ +#!/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'); + +let activeHost = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const windowShim = { + electron: { + httpRequest: () => Promise.reject(new Error('httpRequest stub not configured')), + }, +}; + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + 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: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request === 'react') return fakeReact; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + Array, + Blob, + console, + Date, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + URL, + URLSearchParams, + window: windowShim, + exports: module.exports, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function httpResponse(body, status = 200) { + return { + status, + statusText: status >= 200 && status < 300 ? 'OK' : 'Error', + headers: { 'content-type': 'application/json' }, + url: 'https://api.test/items', + bodyText: JSON.stringify(body), + }; +} + +const { useFetch } = loadTsModule('src/renderer/src/raycast-api/hooks/use-fetch.ts'); + +test('useFetch keeps the latest revalidate result when an older request resolves later', async () => { + const pending = []; + const requestedUrls = []; + const onDataCalls = []; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + + const host = new HookHost(() => useFetch('https://api.test/items', { + onData: (data) => onDataCalls.push(data.value), + })); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.deepEqual(requestedUrls, ['https://api.test/items', 'https://api.test/items']); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.error, undefined); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useFetch refetches when a function URL resolves to a new request input', async () => { + const pending = []; + const requestedUrls = []; + let query = 'one'; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + + const host = new HookHost(() => useFetch(() => `https://api.test/search?q=${query}`)); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + assert.deepEqual(requestedUrls, ['https://api.test/search?q=one']); + + query = 'two'; + host.render(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.deepEqual(requestedUrls, [ + 'https://api.test/search?q=one', + 'https://api.test/search?q=two', + ]); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + host.unmount(); +}); diff --git a/scripts/test-use-form-error-state-noop.mjs b/scripts/test-use-form-error-state-noop.mjs new file mode 100644 index 00000000..d19c58bb --- /dev/null +++ b/scripts/test-use-form-error-state-noop.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const USE_FORM_PATH = 'src/renderer/src/raycast-api/hooks/use-form.ts'; +const FORM_RUNTIME_STATE_PATH = 'src/renderer/src/raycast-api/form-runtime-state.ts'; +const FIELD_CHANGE_COUNTS = [10, 100, 1000]; + +function importStateHelpers() { + const source = fs.readFileSync(FORM_RUNTIME_STATE_PATH, 'utf8'); + const executableSource = source + .replace(/export type FormErrorMap = Record;\n\n/, '') + .replace(/export function /g, 'function ') + .replace(/: FormErrorMap/g, '') + .replace(/: string/g, ''); + + const context = {}; + vm.runInNewContext(`${executableSource}\nthis.clearFormFieldError = clearFormFieldError;\nthis.setFormFieldError = setFormFieldError;`, context); + return { + clearFormFieldError: context.clearFormFieldError, + setFormFieldError: context.setFormFieldError, + }; +} + +function analyzeUseFormSource() { + const source = fs.readFileSync(USE_FORM_PATH, 'utf8'); + return { + importsStateHelpers: source.includes("from '../form-runtime-state'"), + usesClearHelper: source.includes('clearFormFieldError(prev, key as string)'), + usesSetValidationErrorHelper: source.includes('setFormFieldError(prev, key as string, error)'), + usesBlurSetHelper: source.includes('setFormFieldError(prev, key as string, err)'), + removedCloneDeleteClear: !source.includes('const next = { ...prev };\n delete next[key];\n return next;'), + }; +} + +function previousClearFormFieldError(previous, id) { + const next = { ...previous }; + delete next[id]; + return next; +} + +function measureNoErrorFieldChanges(clearFormFieldError, fieldChanges) { + let errors = {}; + let currentSnapshot = errors; + let errorIdentityChanges = 0; + + for (let index = 0; index < fieldChanges; index += 1) { + const next = clearFormFieldError(errors, `field-${index}`); + if (next !== currentSnapshot) { + errorIdentityChanges += 1; + } + currentSnapshot = next; + errors = next; + } + + return { fieldChanges, errorIdentityChanges }; +} + +function getMetrics() { + const { clearFormFieldError, setFormFieldError } = importStateHelpers(); + return { + noExistingErrors: FIELD_CHANGE_COUNTS.map((count) => { + const before = measureNoErrorFieldChanges(previousClearFormFieldError, count); + const after = measureNoErrorFieldChanges(clearFormFieldError, count); + return { + fieldChanges: count, + beforeIdentityChanges: before.errorIdentityChanges, + afterIdentityChanges: after.errorIdentityChanges, + }; + }), + clearExistingErrorPreservesOthers: (() => { + const previous = { first: 'Required', second: 'Invalid' }; + const next = clearFormFieldError(previous, 'first'); + return { + changedIdentity: next !== previous, + clearedFieldMissing: !Object.prototype.hasOwnProperty.call(next, 'first'), + preservedOtherField: next.second === 'Invalid', + }; + })(), + setSameError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Required'); + return { changedIdentity: next !== previous }; + })(), + setNewError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Invalid'); + return { changedIdentity: next !== previous, value: next.first }; + })(), + }; +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ source: analyzeUseFormSource(), metrics: getMetrics() }, null, 2)); +} else { + test('useForm setValue skips error state updates when the field has no error', () => { + const source = analyzeUseFormSource(); + assert.equal(source.importsStateHelpers, true, 'useForm should import the guarded error state helpers'); + assert.equal(source.usesClearHelper, true, 'useForm setValue should use the guarded error clear helper'); + assert.equal(source.removedCloneDeleteClear, true, 'useForm setValue should not clone errors for no-op clears'); + + for (const metric of getMetrics().noExistingErrors) { + assert.equal(metric.beforeIdentityChanges, metric.fieldChanges, `${metric.fieldChanges} previous no-error changes cloned errors every time`); + assert.equal(metric.afterIdentityChanges, 0, `${metric.fieldChanges} no-error changes should keep the same errors object`); + } + }); + + test('useForm setValue still clears an existing field error only', () => { + const metric = getMetrics().clearExistingErrorPreservesOthers; + assert.equal(metric.changedIdentity, true, 'clearing an existing error should publish a new errors object'); + assert.equal(metric.clearedFieldMissing, true, 'the changed field error should be removed'); + assert.equal(metric.preservedOtherField, true, 'unrelated field errors should be preserved'); + }); + + test('useForm validation error writes skip same-value updates but publish changed errors', () => { + const source = analyzeUseFormSource(); + assert.equal(source.usesSetValidationErrorHelper, true, 'useForm setValidationError should use the guarded error set helper'); + assert.equal(source.usesBlurSetHelper, true, 'useForm onBlur validation should use the guarded error set helper'); + + const metrics = getMetrics(); + assert.equal(metrics.setSameError.changedIdentity, false, 'setting the same error should keep the previous errors object'); + assert.equal(metrics.setNewError.changedIdentity, true, 'setting a different error should publish a new errors object'); + assert.equal(metrics.setNewError.value, 'Invalid'); + }); +} diff --git a/scripts/test-use-frecency-sorting.mjs b/scripts/test-use-frecency-sorting.mjs new file mode 100644 index 00000000..4e6ca45e --- /dev/null +++ b/scripts/test-use-frecency-sorting.mjs @@ -0,0 +1,351 @@ +#!/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 hookPath = path.resolve('src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts'); +const contextScopePath = path.resolve('src/renderer/src/raycast-api/context-scope-runtime.ts'); + +function createLocalStorage(initial = {}) { + const store = new Map(Object.entries(initial)); + + return { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(key, String(value)); + }, + removeItem(key) { + store.delete(key); + }, + clear() { + store.clear(); + }, + entries() { + return Array.from(store.entries()); + }, + }; +} + +function createDateController(initialNow) { + let now = initialNow; + let nowCalls = 0; + + class FakeDate extends Date { + constructor(...args) { + if (args.length === 0) { + super(now); + return; + } + super(...args); + } + + static now() { + nowCalls += 1; + return now; + } + } + + return { + Date: FakeDate, + get nowCalls() { + return nowCalls; + }, + resetNowCalls() { + nowCalls = 0; + }, + setNow(nextNow) { + now = nextNow; + }, + }; +} + +function createReactMock() { + const hooks = []; + let hookIndex = 0; + + function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return previous.some((value, index) => !Object.is(value, next[index])); + } + + function memoize(factory, deps) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index] || depsChanged(hooks[index].deps, deps)) { + hooks[index] = { + deps: deps ? [...deps] : deps, + value: factory(), + }; + } + + return hooks[index].value; + } + + return { + reset() { + hookIndex = 0; + }, + react: { + useState(initializer) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) { + hooks[index] = { + value: typeof initializer === 'function' ? initializer() : initializer, + }; + } + + const setState = (nextValue) => { + hooks[index].value = typeof nextValue === 'function' ? nextValue(hooks[index].value) : nextValue; + }; + + return [hooks[index].value, setState]; + }, + useMemo(factory, deps) { + return memoize(factory, deps); + }, + useCallback(callback, deps) { + return memoize(() => callback, deps); + }, + }, + }; +} + +function loadHook({ + extensionName = 'default-extension', + initialNow = Date.UTC(2026, 0, 1, 12), + localStorage = createLocalStorage(), +} = {}) { + const dateController = createDateController(initialNow); + const reactMock = createReactMock(); + const moduleCache = new Map(); + + function resolveTsModule(request, fromPath) { + if (!request.startsWith('.')) return null; + const resolved = path.resolve(path.dirname(fromPath), request); + const candidates = [resolved, `${resolved}.ts`, `${resolved}.tsx`, path.join(resolved, 'index.ts')]; + const match = candidates.find((candidate) => fs.existsSync(candidate)); + if (!match) throw new Error(`Unable to resolve ${request} from ${fromPath}`); + return match; + } + + function loadTsModule(filePath) { + if (moduleCache.has(filePath)) return moduleCache.get(filePath).exports; + + const source = fs.readFileSync(filePath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + moduleCache.set(filePath, module); + + const sandbox = { + module, + exports: module.exports, + require: (request) => { + if (request === 'react') return reactMock.react; + const resolved = resolveTsModule(request, filePath); + return resolved ? loadTsModule(resolved) : require(request); + }, + console, + Date: dateController.Date, + JSON, + localStorage, + Math, + Object, + Promise, + String, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports; + } + + const contextScope = loadTsModule(contextScopePath); + contextScope.configureContextScopeRuntime({ + getExtensionContext: () => ({ + extensionName, + extensionDisplayName: extensionName, + extensionIconDataUrl: '', + commandName: 'test-command', + assetsPath: '', + supportPath: '/tmp/supercmd-test', + owner: 'test-owner', + preferences: {}, + preferenceDefinitions: [], + commandMode: 'view', + }), + setExtensionContext: () => {}, + }); + + const hookModule = loadTsModule(hookPath); + + return { + dateController, + localStorage, + render(data, options) { + reactMock.reset(); + return hookModule.useFrecencySorting(data, options); + }, + }; +} + +function ids(items) { + return Array.from(items, (item) => item.id); +} + +function serializedBytes(localStorage, key) { + return Buffer.byteLength(localStorage.getItem(key) || '', 'utf8'); +} + +function retainedEntryCount(localStorage, key) { + return Object.keys(JSON.parse(localStorage.getItem(key) || '{}')).length; +} + +test('useFrecencySorting reuses sorted data on stable rerender with the default key', () => { + const harness = loadHook(); + const data = [{ id: 'charlie' }, { id: 'alpha' }, { id: 'bravo' }]; + let comparisons = 0; + const options = { + namespace: 'stable-rerender', + sortUnvisited: (a, b) => { + comparisons += 1; + return a.id.localeCompare(b.id); + }, + }; + + const firstRender = harness.render(data, options); + + assert.deepEqual(ids(firstRender.data), ['alpha', 'bravo', 'charlie']); + assert.ok(comparisons > 0, 'initial render should sort the unvisited items'); + + const initialComparisons = comparisons; + const secondRender = harness.render(data, options); + + assert.deepEqual(ids(secondRender.data), ['alpha', 'bravo', 'charlie']); + assert.equal(comparisons, initialComparisons, 'stable rerenders should not recompute the sort'); + assert.strictEqual(secondRender.data, firstRender.data, 'stable rerenders should keep the memoized array'); +}); + +test('useFrecencySorting reads the current time once per sorting recompute', () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'single-now'; + const harness = loadHook({ + initialNow: now, + localStorage: createLocalStorage({ + [`sc-frecency:default-extension:${namespace}`]: JSON.stringify({ + alpha: { count: 1, lastVisited: now - 1_000 }, + bravo: { count: 3, lastVisited: now - 1_000 }, + charlie: { count: 2, lastVisited: now - 1_000 }, + }), + }), + }); + + harness.dateController.resetNowCalls(); + const result = harness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'charlie' }], { namespace }); + + assert.deepEqual(ids(result.data), ['bravo', 'charlie', 'alpha']); + assert.equal(harness.dateController.nowCalls, 1, 'sorting should capture one timestamp per recompute'); +}); + +test('useFrecencySorting preserves visit tracking and reset behavior', async () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'visit-tracking'; + const data = [{ id: 'alpha' }, { id: 'bravo' }, { id: 'charlie' }]; + const harness = loadHook({ initialNow: now }); + let result = harness.render(data, { namespace }); + + harness.dateController.setNow(now - 4 * 60 * 60 * 1_000); + await result.visitItem(data[0]); + harness.dateController.setNow(now - 60 * 60 * 1_000); + await result.visitItem(data[1]); + harness.dateController.setNow(now - 30 * 60 * 1_000); + await result.visitItem(data[1]); + + harness.dateController.setNow(now); + result = harness.render(data, { namespace }); + + assert.deepEqual(ids(result.data), ['bravo', 'alpha', 'charlie']); + assert.deepEqual(JSON.parse(harness.localStorage.getItem(`sc-frecency:default-extension:${namespace}`)), { + alpha: { count: 1, lastVisited: now - 4 * 60 * 60 * 1_000 }, + bravo: { count: 2, lastVisited: now - 30 * 60 * 1_000 }, + }); + + await result.resetRanking(data[1]); + result = harness.render(data, { namespace }); + + assert.equal(result.data[0].id, 'alpha'); + assert.deepEqual(JSON.parse(harness.localStorage.getItem(`sc-frecency:default-extension:${namespace}`)), { + alpha: { count: 1, lastVisited: now - 4 * 60 * 60 * 1_000 }, + }); +}); + +test('useFrecencySorting scopes storage by extension and migrates legacy rankings without loss', async () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'shared-namespace'; + const legacyKey = `sc-frecency-${namespace}`; + const alphaKey = `sc-frecency:alpha-extension:${namespace}`; + const bravoKey = `sc-frecency:bravo-extension:${namespace}`; + const localStorage = createLocalStorage({ + [legacyKey]: JSON.stringify({ + alpha: { count: 2, lastVisited: now - 2 * 60 * 60 * 1_000 }, + legacyOnly: { count: 1, lastVisited: now - 6 * 60 * 60 * 1_000 }, + }), + }); + const legacyBytes = serializedBytes(localStorage, legacyKey); + + assert.equal(retainedEntryCount(localStorage, legacyKey), 2, 'legacy fixture should start with two retained entries'); + assert.equal(legacyBytes, 102, 'legacy fixture should start with 102 serialized bytes'); + + const alphaHarness = loadHook({ extensionName: 'alpha-extension', initialNow: now, localStorage }); + let alphaResult = alphaHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(alphaResult.data), ['alpha', 'legacyOnly', 'bravo']); + assert.deepEqual(JSON.parse(localStorage.getItem(alphaKey)), JSON.parse(localStorage.getItem(legacyKey))); + assert.equal(retainedEntryCount(localStorage, alphaKey), 2, 'migration should retain all legacy entries'); + assert.equal(serializedBytes(localStorage, alphaKey), legacyBytes, 'migration should copy the full serialized payload'); + + alphaHarness.dateController.setNow(now + 1_000); + await alphaResult.visitItem({ id: 'bravo' }); + alphaResult = alphaHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(alphaResult.data), ['alpha', 'bravo', 'legacyOnly']); + assert.equal(retainedEntryCount(localStorage, alphaKey), 3, 'new visits should preserve migrated rankings'); + assert.equal(serializedBytes(localStorage, alphaKey), 150, 'alpha scoped storage should retain three serialized entries'); + assert.equal(retainedEntryCount(localStorage, legacyKey), 2, 'legacy migration should not mutate the legacy payload'); + + const bravoHarness = loadHook({ extensionName: 'bravo-extension', initialNow: now, localStorage }); + let bravoResult = bravoHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(bravoResult.data), ['alpha', 'legacyOnly', 'bravo']); + assert.deepEqual(JSON.parse(localStorage.getItem(bravoKey)), JSON.parse(localStorage.getItem(legacyKey))); + assert.notDeepEqual(JSON.parse(localStorage.getItem(bravoKey)), JSON.parse(localStorage.getItem(alphaKey))); + + bravoHarness.dateController.setNow(now + 2_000); + await bravoResult.visitItem({ id: 'legacyOnly' }); + bravoResult = bravoHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(bravoResult.data), ['legacyOnly', 'alpha', 'bravo']); + assert.equal(retainedEntryCount(localStorage, bravoKey), 2, 'visiting an existing migrated entry should not prune entries'); + assert.equal(serializedBytes(localStorage, bravoKey), legacyBytes, 'bravo scoped storage should retain two serialized entries'); + assert.equal(retainedEntryCount(localStorage, alphaKey), 3, 'other extension scoped storage should be isolated'); + assert.ok(serializedBytes(localStorage, alphaKey) > serializedBytes(localStorage, legacyKey)); + assert.ok(serializedBytes(localStorage, bravoKey) >= serializedBytes(localStorage, legacyKey)); +}); diff --git a/scripts/test-use-stream-json-lifecycle.mjs b/scripts/test-use-stream-json-lifecycle.mjs new file mode 100644 index 00000000..5e18d6be --- /dev/null +++ b/scripts/test-use-stream-json-lifecycle.mjs @@ -0,0 +1,365 @@ +#!/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 hookPath = path.resolve('src/renderer/src/raycast-api/hooks/use-stream-json.ts'); + +let activeHost = null; +let activeFetch = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const { useStreamJSON } = loadUseStreamJson(); + +function loadUseStreamJson() { + const source = fs.readFileSync(hookPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: hookPath, + }); + + const module = { exports: {} }; + const sandbox = { + AbortController, + AbortSignal, + Array, + Blob, + console, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + RegExp, + Request, + Response, + String, + URL, + URLSearchParams, + exports: module.exports, + fetch: (...args) => { + if (!activeFetch) throw new Error('test fetch is not installed'); + return activeFetch(...args); + }, + queueMicrotask, + require: (request) => { + if (request === 'react') return fakeReact; + return require(request); + }, + setTimeout, + clearTimeout, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: hookPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + this.stateUpdatesAfterUnmount = 0; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + if (this.unmounted) { + this.stateUpdatesAfterUnmount += 1; + return; + } + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +function createFetchMock() { + const requests = []; + + return { + requests, + fetch(url, init = {}) { + const pending = deferred(); + const request = { + url, + init, + promise: pending.promise, + resolveJson(value, responseOptions) { + pending.resolve(jsonResponse(value, responseOptions)); + }, + reject(error) { + pending.reject(error); + }, + }; + requests.push(request); + return pending.promise; + }, + }; +} + +function jsonResponse(value, { ok = true, status = 200 } = {}) { + return { + ok, + status, + async json() { + return value; + }, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +test('useStreamJSON keeps the latest revalidation result and callbacks', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + + fetchMock.requests[0].resolveJson([{ id: 'stale' }]); + await flushAsync(); + + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'previous fetch signal should be aborted'); + + host.unmount(); +}); + +test('useStreamJSON suppresses stale errors from older runs', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + fetchMock.requests[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.error, undefined); + assert.deepEqual(onError, []); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + + host.unmount(); +}); + +test('useStreamJSON aborts and ignores active work after unmount', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.unmount(); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'active fetch signal should be aborted on unmount'); + + fetchMock.requests[0].resolveJson([{ id: 'late-after-unmount' }]); + await flushAsync(); + + assert.equal(host.stateUpdatesAfterUnmount, 0); + assert.deepEqual(onData, []); + assert.deepEqual(onError, []); +}); + +test('useStreamJSON composes caller signal with lifecycle aborts', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const callerController = new AbortController(); + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + signal: callerController.signal, + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + const firstSignal = fetchMock.requests[0].init.signal; + assert.ok(firstSignal instanceof AbortSignal, 'fetch should receive an abort signal'); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + assert.equal(firstSignal.aborted, true, 'lifecycle abort should cancel the previous composed signal'); + assert.equal(callerController.signal.aborted, false, 'lifecycle abort should not abort the caller-owned signal'); + + const secondSignal = fetchMock.requests[1].init.signal; + assert.ok(secondSignal instanceof AbortSignal, 'revalidated fetch should receive an abort signal'); + callerController.abort(); + assert.equal(secondSignal.aborted, true, 'caller abort should cancel the composed signal'); + + host.unmount(); +}); diff --git a/scripts/test-whisper-file-buffer-read.mjs b/scripts/test-whisper-file-buffer-read.mjs new file mode 100644 index 00000000..f0abf780 --- /dev/null +++ b/scripts/test-whisper-file-buffer-read.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const { + resolveWhisperFileTranscriptionPlan, + shouldReadWhisperFileAudioBuffer, + transcribeWhisperAudioFile, +} = await importTs(path.resolve('src/main/whisper-transcribe-file.ts')); + +const AUDIO_PATH = '/tmp/supercmd-whisper-file-buffer-read/input.wav'; +const DEFAULT_FILE_BYTES = 73_400_320; + +function makeSettings(speechToTextModel) { + return { + ai: { + enabled: true, + whisperEnabled: true, + speechToTextModel, + speechLanguage: 'en-US', + openaiApiKey: 'openai-key', + elevenlabsApiKey: 'elevenlabs-key', + mistralApiKey: 'mistral-key', + }, + }; +} + +function makeHarness({ speechToTextModel = 'whispercpp', exists = true, fileBytes = DEFAULT_FILE_BYTES } = {}) { + const events = []; + const fakeAudioBuffer = Buffer.from('fake wav bytes'); + const calls = { + parakeet: [], + qwen3: [], + openai: [], + elevenlabs: [], + mistral: [], + whispercpp: [], + }; + let readCalls = 0; + let nodeBufferBytes = 0; + + const fs = { + existsSync(filePath) { + events.push(`exists:${filePath}`); + return exists; + }, + readFileSync(filePath) { + events.push(`read:${filePath}`); + readCalls += 1; + nodeBufferBytes += fileBytes; + return fakeAudioBuffer; + }, + unlinkSync(filePath) { + events.push(`unlink:${filePath}`); + }, + rmdirSync(dirPath, options) { + events.push(`rmdir:${dirPath}:${JSON.stringify(options)}`); + }, + }; + + const makeTranscriber = (label) => async (opts) => { + events.push(`transcribe:${label}`); + calls[label].push(opts); + return `${label} transcript`; + }; + + const deps = { + fs, + loadSettings: () => makeSettings(speechToTextModel), + isAIDisabledInSettings: (settings) => settings.ai?.enabled === false, + normalizeWhisperLanguageCode: (rawLanguage) => String(rawLanguage || 'en-US').split('-')[0].toLowerCase(), + resolveElevenLabsSttModel: (model) => model.replace(/^elevenlabs-/, '').replace(/-/g, '_') || 'scribe_v1', + getElevenLabsApiKey: (settings) => settings.ai?.elevenlabsApiKey || '', + getMistralApiKey: (settings) => settings.ai?.mistralApiKey || '', + getWhisperCppModelStatus: () => { + events.push('status:whispercpp'); + return { state: 'downloaded' }; + }, + ensureWhisperCppServer: async () => { + events.push('ensure:whispercpp'); + }, + sendWhisperCppRequest: async (request) => { + events.push('send:whispercpp'); + calls.whispercpp.push(request); + return { text: 'whispercpp transcript' }; + }, + transcribeAudioWithParakeet: makeTranscriber('parakeet'), + transcribeAudioWithQwen3: makeTranscriber('qwen3'), + transcribeAudioWithElevenLabs: makeTranscriber('elevenlabs'), + transcribeAudioWithMistralVoxtral: makeTranscriber('mistral'), + transcribeAudio: makeTranscriber('openai'), + whisperCppModelName: 'base', + }; + + return { + calls, + deps, + events, + fakeAudioBuffer, + fileBytes, + get nodeBufferBytes() { + return nodeBufferBytes; + }, + get readCalls() { + return readCalls; + }, + }; +} + +function assertCleanupAfter(events, marker) { + const markerIndex = events.indexOf(marker); + const unlinkIndex = events.findIndex((event) => event.startsWith('unlink:')); + const rmdirIndex = events.findIndex((event) => event.startsWith('rmdir:')); + + assert.notEqual(markerIndex, -1, `${marker} should be recorded`); + assert.ok(unlinkIndex > markerIndex, 'temp audio file should be unlinked after transcription succeeds'); + assert.ok(rmdirIndex > unlinkIndex, 'temp audio directory should be removed after the file unlink'); +} + +test('provider plan marks only buffer-backed file providers as requiring a Node buffer', () => { + const cases = [ + ['', 'whispercpp', false], + ['default', 'whispercpp', false], + ['whispercpp', 'whispercpp', false], + ['custom-local-model', 'whispercpp', false], + ['native', 'native', false], + ['parakeet', 'parakeet', true], + ['qwen3', 'qwen3', true], + ['openai-whisper-1', 'openai', true], + ['elevenlabs-scribe-v2', 'elevenlabs', true], + ['mistral-voxtral-mini-latest', 'mistral', true], + ]; + + for (const [speechToTextModel, expectedProvider, expectedRead] of cases) { + const plan = resolveWhisperFileTranscriptionPlan(speechToTextModel, { + whisperCppModelName: 'base', + resolveElevenLabsSttModel: (model) => model, + }); + assert.equal(plan.provider, expectedProvider, `${speechToTextModel || '(empty)'} provider`); + assert.equal( + shouldReadWhisperFileAudioBuffer(plan.provider), + expectedRead, + `${speechToTextModel || '(empty)'} read requirement` + ); + } +}); + +test('whispercpp file transcription sends the file path without reading a Node buffer', async () => { + const harness = makeHarness({ speechToTextModel: 'whispercpp' }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, 'whispercpp transcript'); + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.deepEqual(harness.calls.whispercpp, [ + { command: 'transcribe', file: AUDIO_PATH, language: 'en' }, + ]); + assertCleanupAfter(harness.events, 'send:whispercpp'); +}); + +test('buffer-backed file providers read the audio file once and clean up after transcription', async () => { + const cases = [ + ['parakeet', 'parakeet'], + ['qwen3', 'qwen3'], + ['openai-whisper-1', 'openai'], + ['elevenlabs-scribe-v2', 'elevenlabs'], + ['mistral-voxtral-mini-latest', 'mistral'], + ]; + + for (const [speechToTextModel, label] of cases) { + const harness = makeHarness({ speechToTextModel }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, `${label} transcript`); + assert.equal(harness.readCalls, 1, `${label} should read the captured file exactly once`); + assert.equal(harness.nodeBufferBytes, harness.fileBytes, `${label} should account for one full file buffer read`); + assert.equal(harness.calls[label].length, 1, `${label} transcriber should be called once`); + assert.equal(harness.calls[label][0].audioBuffer, harness.fakeAudioBuffer); + assert.equal(harness.calls[label][0].language, 'en'); + assert.equal(harness.calls[label][0].mimeType, 'audio/wav'); + assert.equal(harness.calls.whispercpp.length, 0, `${label} should not use the whisper.cpp request path`); + assertCleanupAfter(harness.events, `transcribe:${label}`); + } +}); + +test('missing audio file does not read a buffer or run cleanup', async () => { + const harness = makeHarness({ speechToTextModel: 'openai-whisper-1', exists: false }); + + await assert.rejects( + transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: harness.deps, + }), + /Audio file not found/ + ); + + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.equal(harness.events.some((event) => event.startsWith('unlink:')), false); + assert.equal(harness.events.some((event) => event.startsWith('rmdir:')), false); +}); + +test('instrumentation shows whispercpp avoids the legacy eager full-file read', async () => { + const legacy = makeHarness({ speechToTextModel: 'whispercpp' }); + legacy.deps.fs.readFileSync(AUDIO_PATH); + + const whisperCpp = makeHarness({ speechToTextModel: 'whispercpp' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: whisperCpp.deps, + }); + + const openai = makeHarness({ speechToTextModel: 'openai-whisper-1' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: openai.deps, + }); + + console.log( + `[whisper-file-buffer-read] before provider=whispercpp readCalls=${legacy.readCalls} ` + + `nodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=whispercpp readCalls=${whisperCpp.readCalls} ` + + `nodeBufferBytes=${whisperCpp.nodeBufferBytes} avoidedNodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=openai readCalls=${openai.readCalls} ` + + `nodeBufferBytes=${openai.nodeBufferBytes}` + ); + + assert.equal(legacy.readCalls, 1); + assert.equal(legacy.nodeBufferBytes, DEFAULT_FILE_BYTES); + assert.equal(whisperCpp.readCalls, 0); + assert.equal(whisperCpp.nodeBufferBytes, 0); + assert.equal(openai.readCalls, 1); + assert.equal(openai.nodeBufferBytes, DEFAULT_FILE_BYTES); +}); diff --git a/scripts/test-with-cache-expiry.mjs b/scripts/test-with-cache-expiry.mjs new file mode 100644 index 00000000..41630993 --- /dev/null +++ b/scripts/test-with-cache-expiry.mjs @@ -0,0 +1,119 @@ +#!/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'); + +function loadPlatformRuntimeWithObservedMaps() { + const filePath = path.resolve('src/renderer/src/raycast-api/platform-runtime.ts'); + const source = fs.readFileSync(filePath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const observedMaps = []; + class ObservableMap extends Map { + constructor(...args) { + super(...args); + observedMaps.push(this); + } + } + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Error, + JSON, + Map: ObservableMap, + Promise, + window: {}, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return { exports: module.exports, observedMaps }; +} + +test('withCache sweeps expired unique keys when maxAge is configured', async () => { + const originalNow = Date.now; + const uniqueKeyCount = 1_000; + let now = 1_000; + let calls = 0; + + Date.now = () => now; + try { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + const cached = exports.withCache(async (key) => { + calls += 1; + return `value:${key}:${calls}`; + }, { maxAge: 10 }); + + for (let index = 0; index < uniqueKeyCount; index += 1) { + await cached(`expired-${index}`); + now += 11; + } + + await cached('latest'); + + const retainedSize = observedMaps[0]?.size; + console.log(`[withCache metric] retained entries after unique expired inserts: ${retainedSize}`); + assert.equal(retainedSize, 1); + } finally { + Date.now = originalNow; + } +}); + +test('withCache recomputes and replaces an expired hit', async () => { + const originalNow = Date.now; + let now = 1_000; + let calls = 0; + + Date.now = () => now; + try { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + const cached = exports.withCache(async (key) => { + calls += 1; + return { key, calls }; + }, { maxAge: 10 }); + + assert.deepEqual(await cached('same-key'), { key: 'same-key', calls: 1 }); + now += 11; + assert.deepEqual(await cached('same-key'), { key: 'same-key', calls: 2 }); + assert.equal(observedMaps[0]?.size, 1); + } finally { + Date.now = originalNow; + } +}); + +test('withCache retains non-expiring entries until clearCache is called', async () => { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + let calls = 0; + const cached = exports.withCache(async (key) => { + calls += 1; + return `value:${key}:${calls}`; + }); + + assert.equal(await cached('a'), 'value:a:1'); + assert.equal(await cached('b'), 'value:b:2'); + assert.equal(await cached('a'), 'value:a:1'); + assert.equal(observedMaps[0]?.size, 2); + assert.equal(calls, 2); + + cached.clearCache(); + assert.equal(observedMaps[0]?.size, 0); +}); 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/ai-provider.ts b/src/main/ai-provider.ts index 65942908..e2fbc6ed 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -398,18 +398,7 @@ async function* streamGeminiChat( useHttps: true, }); - yield* parseSSE(response, (data) => { - try { - const parsed = JSON.parse(data); - const parts = parsed?.candidates?.[0]?.content?.parts; - if (Array.isArray(parts)) { - return parts.map((p: any) => (typeof p?.text === 'string' ? p.text : '')).join('') || null; - } - return null; - } catch { - return null; - } - }); + yield* streamGeminiSSE(response); } async function* streamOllamaChat( @@ -608,7 +597,7 @@ async function* streamGemini( const response = await httpRequest({ hostname: 'generativelanguage.googleapis.com', - path: `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, + path: `/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -616,21 +605,54 @@ async function* streamGemini( useHttps: true, }); - const parsed = JSON.parse(await readResponseBody(response) || '{}'); - const text = Array.isArray(parsed?.candidates?.[0]?.content?.parts) - ? parsed.candidates[0].content.parts - .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) - .join('') - : ''; + yield* streamGeminiSSE(response, { throwOnNoText: true }); +} + +function extractGeminiResponseText(parsed: any): string { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; - if (text) { - yield text; - return; - } + return parts + .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); +} + +function extractGeminiNoTextReason(parsed: any): string { + return String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); +} + +function createGeminiNoTextError(reason: string): Error { + if (reason) return new Error(`Gemini returned no text (${reason}).`); + return new Error('Gemini returned no text.'); +} + +async function* streamGeminiSSE( + response: http.IncomingMessage, + options: { throwOnNoText?: boolean } = {} +): AsyncGenerator { + let yieldedText = false; + let noTextReason = ''; + + yield* parseSSE(response, (data) => { + try { + const parsed = JSON.parse(data); + const text = extractGeminiResponseText(parsed); + if (text) { + yieldedText = true; + return text; + } - const reason = String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); - if (reason) throw new Error(`Gemini returned no text (${reason}).`); - throw new Error('Gemini returned no text.'); + const reason = extractGeminiNoTextReason(parsed); + if (reason && !noTextReason) noTextReason = reason; + return null; + } catch { + return null; + } + }); + + if (options.throwOnNoText && !yieldedText) { + throw createGeminiNoTextError(noTextReason); + } } // ─── Ollama ────────────────────────────────────────────────────────── @@ -687,6 +709,29 @@ interface HttpRequestOptions { function httpRequest(opts: HttpRequestOptions): Promise { return new Promise((resolve, reject) => { const mod = opts.useHttps ? https : http; + let settled = false; + let onAbort: (() => void) | null = null; + + const cleanup = () => { + if (opts.signal && onAbort) { + opts.signal.removeEventListener('abort', onAbort); + onAbort = null; + } + }; + + const finish = (settle: (value: T) => void, value: T, shouldCleanup = true) => { + if (settled) return; + settled = true; + if (shouldCleanup) cleanup(); + settle(value); + }; + + const cleanupAfterResponse = (res: http.IncomingMessage) => { + const cleanupOnce = () => cleanup(); + res.once('end', cleanupOnce); + res.once('close', cleanupOnce); + res.once('error', cleanupOnce); + }; const reqOpts: https.RequestOptions = { hostname: opts.hostname, @@ -701,25 +746,31 @@ function httpRequest(opts: HttpRequestOptions): Promise { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { - reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`)); + finish(reject, new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`)); }); return; } - resolve(res); + cleanupAfterResponse(res); + finish(resolve, res, false); }); - req.on('error', reject); + req.on('error', (err) => { + finish(reject, err); + }); if (opts.signal) { if (opts.signal.aborted) { + cleanup(); + finish(reject, new Error('Request aborted')); req.destroy(); - reject(new Error('Request aborted')); return; } - opts.signal.addEventListener('abort', () => { + onAbort = () => { + cleanup(); + finish(reject, new Error('Request aborted')); req.destroy(); - reject(new Error('Request aborted')); - }, { once: true }); + }; + opts.signal.addEventListener('abort', onAbort, { once: true }); } req.write(opts.body); @@ -727,14 +778,6 @@ function httpRequest(opts: HttpRequestOptions): Promise { }); } -async function readResponseBody(response: http.IncomingMessage): Promise { - let body = ''; - for await (const rawChunk of response) { - body += rawChunk.toString(); - } - return body; -} - async function* parseSSE( response: http.IncomingMessage, extractChunk: (data: string) => string | null @@ -822,10 +865,17 @@ function resolveUploadMeta(mimeType?: string): { filename: string; contentType: return { filename: 'audio.webm', contentType: 'audio/webm' }; } -export function transcribeAudio(opts: TranscribeOptions): Promise { - const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; - const uploadMeta = resolveUploadMeta(opts.mimeType); +interface MultipartUpload { + parts: Buffer[]; + contentLength: number; +} + +function getMultipartContentLength(parts: readonly Buffer[]): number { + return parts.reduce((total, part) => total + part.length, 0); +} +function buildTranscriptionMultipartUpload(opts: TranscribeOptions, boundary: string): MultipartUpload { + const uploadMeta = resolveUploadMeta(opts.mimeType); const parts: Buffer[] = []; // file field @@ -854,9 +904,41 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); + return { + parts, + contentLength: getMultipartContentLength(parts), + }; +} + +function writeBufferedRequestParts(req: http.ClientRequest, parts: readonly Buffer[]): void { + for (const part of parts) { + req.write(part); + } + req.end(); +} + +export function transcribeAudio(opts: TranscribeOptions): Promise { + const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; + const upload = buildTranscriptionMultipartUpload(opts, boundary); return new Promise((resolve, reject) => { + let settled = false; + let onAbort: (() => void) | null = null; + + const cleanup = () => { + if (opts.signal && onAbort) { + opts.signal.removeEventListener('abort', onAbort); + onAbort = null; + } + }; + + const finish = (settle: (value: T) => void, value: T) => { + if (settled) return; + settled = true; + cleanup(); + settle(value); + }; + const req = https.request( { hostname: 'api.openai.com', @@ -865,7 +947,7 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': upload.contentLength, }, }, (res) => { @@ -873,29 +955,31 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { res.on('data', (chunk) => { responseBody += chunk; }); res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { - reject(new Error(`Whisper API HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); + finish(reject, new Error(`Whisper API HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); return; } - resolve(responseBody.trim()); + finish(resolve, responseBody.trim()); }); } ); - req.on('error', reject); + req.on('error', (err) => { + finish(reject, err); + }); if (opts.signal) { if (opts.signal.aborted) { + finish(reject, new Error('Transcription aborted')); req.destroy(); - reject(new Error('Transcription aborted')); return; } - opts.signal.addEventListener('abort', () => { + onAbort = () => { + finish(reject, new Error('Transcription aborted')); req.destroy(); - reject(new Error('Transcription aborted')); - }, { once: true }); + }; + opts.signal.addEventListener('abort', onAbort, { once: true }); } - req.write(body); - req.end(); + writeBufferedRequestParts(req, upload.parts); }); } diff --git a/src/main/ai-stream-ipc.ts b/src/main/ai-stream-ipc.ts new file mode 100644 index 00000000..662c56de --- /dev/null +++ b/src/main/ai-stream-ipc.ts @@ -0,0 +1,82 @@ +export const AI_STREAM_IPC_FLUSH_MS = 16; + +export interface AIStreamIpcSender { + send: (channel: 'ai-stream-chunk', payload: { requestId: string; chunk: string }) => void; +} + +export interface AIStreamIpcCoalescerOptions { + requestId: string; + sender: AIStreamIpcSender; + flushIntervalMs?: number; + scheduleFlush?: (callback: () => void, delayMs: number) => unknown; + cancelFlush?: (handle: unknown) => void; +} + +export interface AIStreamIpcCoalescer { + appendChunk: (chunk: string) => void; + cancelPendingFlush: () => void; + flush: () => boolean; + hasPendingChunk: () => boolean; + hasPendingFlush: () => boolean; +} + +export function createAIStreamIpcCoalescer({ + requestId, + sender, + flushIntervalMs = AI_STREAM_IPC_FLUSH_MS, + scheduleFlush = (callback, delayMs) => setTimeout(callback, delayMs), + cancelFlush = (handle) => clearTimeout(handle as ReturnType), +}: AIStreamIpcCoalescerOptions): AIStreamIpcCoalescer { + let pendingChunk = ''; + let flushHandle: unknown = null; + + const cancelPendingFlush = () => { + if (flushHandle === null) return; + cancelFlush(flushHandle); + flushHandle = null; + }; + + const flush = () => { + cancelPendingFlush(); + if (!pendingChunk) return false; + const chunk = pendingChunk; + pendingChunk = ''; + sender.send('ai-stream-chunk', { requestId, chunk }); + return true; + }; + + const scheduleNextFlush = () => { + if (flushHandle !== null) return; + flushHandle = scheduleFlush(() => { + flushHandle = null; + flush(); + }, flushIntervalMs); + }; + + return { + appendChunk(chunk) { + if (!chunk) return; + pendingChunk += chunk; + scheduleNextFlush(); + }, + cancelPendingFlush, + flush, + hasPendingChunk() { + return pendingChunk.length > 0; + }, + hasPendingFlush() { + return flushHandle !== null; + }, + }; +} + +export async function forwardAIStreamChunksToIpc( + chunks: AsyncIterable, + coalescer: AIStreamIpcCoalescer, + signal: AbortSignal +): Promise { + for await (const chunk of chunks) { + if (signal.aborted) break; + coalescer.appendChunk(chunk); + } +} 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/canvas-store.ts b/src/main/canvas-store.ts index 983a5ea9..a7a0785d 100644 --- a/src/main/canvas-store.ts +++ b/src/main/canvas-store.ts @@ -245,12 +245,7 @@ export function getScene(id: string): CanvasScene { const scenePath = getScenePath(id); if (fs.existsSync(scenePath)) { const data = fs.readFileSync(scenePath, 'utf-8'); - const parsed = JSON.parse(data); - return { - elements: Array.isArray(parsed.elements) ? parsed.elements : [], - appState: parsed.appState || {}, - files: parsed.files || {}, - }; + return parseSceneData(data); } } catch (e) { console.error(`Failed to load scene for canvas ${id}:`, e); @@ -258,6 +253,27 @@ export function getScene(id: string): CanvasScene { return { elements: [], appState: {}, files: {} }; } +export async function getSceneAsync(id: string): Promise { + try { + const data = await fsp.readFile(getScenePath(id), 'utf-8'); + return parseSceneData(data); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + console.error(`Failed to load scene for canvas ${id}:`, e); + } + } + return { elements: [], appState: {}, files: {} }; +} + +function parseSceneData(data: string): CanvasScene { + const parsed = JSON.parse(data); + return { + elements: Array.isArray(parsed.elements) ? parsed.elements : [], + appState: parsed.appState || {}, + files: parsed.files || {}, + }; +} + export async function saveScene(id: string, scene: CanvasScene): Promise { try { await fsp.writeFile(getScenePath(id), JSON.stringify(scene), 'utf-8'); @@ -296,6 +312,17 @@ export function getThumbnail(id: string): string | null { return null; } +export async function getThumbnailAsync(id: string): Promise { + try { + return await fsp.readFile(getThumbnailPath(id), 'utf-8'); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + console.error(`Failed to load thumbnail for canvas ${id}:`, e); + } + } + return null; +} + // ─── Export ───────────────────────────────────────────────────────── export async function exportCanvas( diff --git a/src/main/clipboard-manager.ts b/src/main/clipboard-manager.ts index fa1453ff..97391be8 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,7 @@ export interface ClipboardItem { const MAX_ITEMS = 1000; const POLL_INTERVAL = 1000; // 1 second +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 +176,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 +222,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 +978,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 +1020,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 +1037,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 +1056,8 @@ export function deleteClipboardItem(id: string): boolean { } clipboardHistory.splice(index, 1); - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); return true; } @@ -1132,7 +1225,7 @@ export function setClipboardMonitorEnabled(enabled: boolean): void { if (enabled && !pollInterval) { startClipboardMonitor(); } else if (!enabled && pollInterval) { - stopClipboardMonitor(); + void stopClipboardMonitor(); } } diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..67dd7406 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -18,7 +18,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { discoverInstalledExtensionCommands } from './extension-runner'; import { discoverScriptCommands } from './script-command-runner'; -import { getAllQuickLinks, getQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; +import { getAllQuickLinks, getQuickLinkCommandId, isQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; import { loadSettings,getSearchApplicationsScope} from './settings-store'; const execAsync = promisify(exec); @@ -112,6 +112,22 @@ let inflightDiscovery: Promise | null = null; let lastStaleRefreshRequestAt = 0; const CACHE_TTL = 30 * 60_000; // 30 min const STALE_REFRESH_COOLDOWN_MS = 15_000; +let commandDiscoveryStartCount = 0; +let commandDiscoveryRunnerForTesting: (() => Promise) | null = null; + +export type CommandRuntimeMetadata = { subtitle?: string | null | undefined }; +type CommandRuntimeMetadataStore = Record; + +export interface CommandMetadataPatchResult { + matchedCommands: number; + changedCommands: number; + patchedCachedCommands: boolean; + patchedStaleCommandsFallback: boolean; +} + +const runtimeMetadataBaseSubtitleByKey = new Map(); +const runtimeMetadataCommandsByKey = new Map>(); +let extensionCommandInfoRunnerForTesting: (() => CommandInfo[]) | null = null; // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -151,7 +167,18 @@ function loadCommandsDiskCache(): CommandInfo[] | null { function saveCommandsDiskCache(commands: CommandInfo[]): void { try { // Strip icon data — icons are persisted separately in icon-cache/. - const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => rest); + const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => { + const commandForDisk = { ...rest }; + const baseSubtitle = getRuntimeMetadataBaseSubtitle(commandForDisk); + if (baseSubtitle.known) { + if (baseSubtitle.subtitle) { + commandForDisk.subtitle = baseSubtitle.subtitle; + } else { + delete commandForDisk.subtitle; + } + } + return commandForDisk; + }); fs.writeFileSync( getCommandsDiskCachePath(), JSON.stringify({ version: COMMANDS_DISK_CACHE_VERSION, commands: stripped }), @@ -166,6 +193,11 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void { export function initCommandsCache(): void { const cmds = loadCommandsDiskCache(); if (cmds) { + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(cmds); + try { + applyStoredRuntimeCommandMetadata(cmds, loadSettings().commandMetadata || {}); + } catch {} cachedCommands = cmds; staleCommandsFallback = cmds; cacheTimestamp = 0; // mark stale so the next getAvailableCommands() triggers a background refresh @@ -178,6 +210,214 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +function normalizeCommandMetadataKey(value: string): string { + return String(value || '').trim(); +} + +function normalizeRuntimeSubtitle(value: string | null | undefined): string { + return String(value ?? '').trim(); +} + +function isRuntimeMetadataSubtitleEligible(command: CommandInfo): boolean { + return !(command.category === 'script' && command.mode !== 'inline'); +} + +function getRuntimeMetadataKeys(command: CommandInfo): string[] { + const keys = [normalizeCommandMetadataKey(command.id)]; + if (command.category === 'extension') { + keys.push(normalizeCommandMetadataKey(command.path || '')); + } + return Array.from(new Set(keys.filter(Boolean))); +} + +function commandMatchesRuntimeMetadataKey(command: CommandInfo, key: string): boolean { + const normalizedKey = normalizeCommandMetadataKey(key); + if (!normalizedKey) return false; + return getRuntimeMetadataKeys(command).includes(normalizedKey); +} + +function resetRuntimeMetadataTracking(): void { + runtimeMetadataBaseSubtitleByKey.clear(); + runtimeMetadataCommandsByKey.clear(); +} + +function rememberRuntimeMetadataBaseSubtitles(commands: CommandInfo[]): void { + for (const command of commands) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + for (const key of getRuntimeMetadataKeys(command)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + let commandsForKey = runtimeMetadataCommandsByKey.get(key); + if (!commandsForKey) { + commandsForKey = new Set(); + runtimeMetadataCommandsByKey.set(key, commandsForKey); + } + commandsForKey.add(command); + } + } +} + +function ensureRuntimeMetadataBaseSubtitle(command: CommandInfo): void { + if (!isRuntimeMetadataSubtitleEligible(command)) return; + for (const key of getRuntimeMetadataKeys(command)) { + if (!runtimeMetadataBaseSubtitleByKey.has(key)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + } + } +} + +function getRuntimeMetadataBaseSubtitle(command: CommandInfo): { known: boolean; subtitle?: string } { + if (!isRuntimeMetadataSubtitleEligible(command)) return { known: false }; + for (const key of getRuntimeMetadataKeys(command)) { + if (runtimeMetadataBaseSubtitleByKey.has(key)) { + return { known: true, subtitle: runtimeMetadataBaseSubtitleByKey.get(key) }; + } + } + return { known: false }; +} + +function getStoredRuntimeSubtitle( + command: CommandInfo, + commandMetadata: CommandRuntimeMetadataStore +): string { + if (!isRuntimeMetadataSubtitleEligible(command)) return ''; + for (const key of getRuntimeMetadataKeys(command)) { + const subtitle = normalizeRuntimeSubtitle(commandMetadata[key]?.subtitle); + if (subtitle) return subtitle; + } + return ''; +} + +function setRuntimeMetadataSubtitle(command: CommandInfo, subtitle: string): boolean { + ensureRuntimeMetadataBaseSubtitle(command); + const previousSubtitle = command.subtitle; + if (subtitle) { + command.subtitle = subtitle; + } else { + const baseSubtitle = getRuntimeMetadataBaseSubtitle(command); + if (baseSubtitle.known && baseSubtitle.subtitle) { + command.subtitle = baseSubtitle.subtitle; + } else { + delete command.subtitle; + } + } + return command.subtitle !== previousSubtitle; +} + +function applyStoredRuntimeCommandMetadata( + commands: CommandInfo[], + commandMetadata: CommandRuntimeMetadataStore +): void { + for (const command of commands) { + const subtitle = getStoredRuntimeSubtitle(command, commandMetadata); + if (subtitle) { + setRuntimeMetadataSubtitle(command, subtitle); + } + } +} + +function startCommandDiscovery(): Promise { + commandDiscoveryStartCount += 1; + return (commandDiscoveryRunnerForTesting || discoverAndBuildCommands)(); +} + +export function applyCommandMetadataUpdate( + commandId: string, + metadata: CommandRuntimeMetadata +): CommandMetadataPatchResult { + const normalizedCommandId = normalizeCommandMetadataKey(commandId); + if (!normalizedCommandId) { + return { + matchedCommands: 0, + changedCommands: 0, + patchedCachedCommands: false, + patchedStaleCommandsFallback: false, + }; + } + + const subtitle = normalizeRuntimeSubtitle(metadata?.subtitle); + const matchedCommandIds = new Set(); + const changedCommandIds = new Set(); + let patchedCachedCommands = false; + let patchedStaleCommandsFallback = false; + + const registeredCommands = runtimeMetadataCommandsByKey.get(normalizedCommandId); + const commandsToPatch = registeredCommands + ? Array.from(registeredCommands) + : [ + ...(cachedCommands || []), + ...((staleCommandsFallback && staleCommandsFallback !== cachedCommands) ? staleCommandsFallback : []), + ].filter((command) => commandMatchesRuntimeMetadataKey(command, normalizedCommandId)); + + for (const command of commandsToPatch) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + if (!commandMatchesRuntimeMetadataKey(command, normalizedCommandId)) continue; + matchedCommandIds.add(command.id); + if (setRuntimeMetadataSubtitle(command, subtitle)) { + changedCommandIds.add(command.id); + if (cachedCommands?.includes(command)) { + patchedCachedCommands = true; + } + if (staleCommandsFallback && staleCommandsFallback !== cachedCommands && staleCommandsFallback.includes(command)) { + patchedStaleCommandsFallback = true; + } + } + } + + return { + matchedCommands: matchedCommandIds.size, + changedCommands: changedCommandIds.size, + patchedCachedCommands, + patchedStaleCommandsFallback, + }; +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options: { cacheTimestamp?: number; staleCommandsFallback?: CommandInfo[] | null } = {} +): void { + cachedCommands = commands; + staleCommandsFallback = options.staleCommandsFallback === undefined + ? commands + : options.staleCommandsFallback; + cacheTimestamp = options.cacheTimestamp ?? Date.now(); + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + if (staleCommandsFallback && staleCommandsFallback !== commands) { + rememberRuntimeMetadataBaseSubtitles(staleCommandsFallback); + } +} + +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + extensionCommandInfoRunnerForTesting = null; + resetRuntimeMetadataTracking(); + commandsDiskCachePath = null; +} + +export function __setCommandDiscoveryRunnerForTesting( + runner: (() => Promise) | null +): void { + commandDiscoveryRunnerForTesting = runner; +} + +export function __setExtensionCommandInfoRunnerForTesting( + runner: (() => CommandInfo[]) | null +): void { + extensionCommandInfoRunnerForTesting = runner; +} + +export function __getCommandDiscoveryStartCountForTesting(): number { + return commandDiscoveryStartCount; +} + // ─── Icon Disk Cache ──────────────────────────────────────────────── let iconCacheDir: string | null = null; @@ -657,6 +897,98 @@ function buildQuickLinkKeywords(quickLink: QuickLink): string[] { return Array.from(set); } +function discoverExtensionCommandInfos(): CommandInfo[] { + if (extensionCommandInfoRunnerForTesting) { + return extensionCommandInfoRunnerForTesting(); + } + + try { + return discoverInstalledExtensionCommands().map((ext) => ({ + id: ext.id, + title: ext.title, + subtitle: ext.extensionTitle, + keywords: ext.keywords, + iconDataUrl: ext.iconDataUrl, + category: 'extension' as const, + path: `${ext.extName}/${ext.cmdName}`, + mode: ext.mode, + interval: ext.interval, + disabledByDefault: ext.disabledByDefault, + commandArgumentDefinitions: ext.commandArgumentDefinitions || [], + deeplink: ext.owner + ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` + : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, + })); + } catch (e) { + console.error('Failed to discover installed extensions:', e); + return []; + } +} + +function discoverScriptCommandInfos(): CommandInfo[] { + try { + return discoverScriptCommands().map((script) => ({ + id: script.id, + title: script.title, + subtitle: script.packageName, + keywords: script.keywords, + iconDataUrl: script.iconDataUrl, + iconEmoji: script.iconEmoji, + category: 'script' as const, + path: script.scriptPath, + mode: script.mode, + interval: script.interval, + needsConfirmation: script.needsConfirmation, + commandArgumentDefinitions: script.arguments.map((arg) => ({ + name: arg.name, + required: arg.required, + type: arg.type, + placeholder: arg.placeholder, + title: arg.placeholder, + data: arg.data, + })), + deeplink: script.slug + ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` + : undefined, + })); + } catch (e) { + console.error('Failed to discover script commands:', e); + return []; + } +} + +async function discoverQuickLinkCommandInfos(): Promise { + try { + const quickLinks = getAllQuickLinks(); + return await Promise.all( + quickLinks.map(async (quickLink) => { + const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); + let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); + + if (!resolvedIconName && quickLink.applicationPath) { + const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); + if (resolvedAppIconDataUrl) { + iconDataUrl = resolvedAppIconDataUrl; + } + } + + return { + id: getQuickLinkCommandId(quickLink.id), + title: quickLink.name, + subtitle: quickLink.applicationName || 'Quick Link', + keywords: buildQuickLinkKeywords(quickLink), + iconDataUrl, + iconName: iconDataUrl ? undefined : resolvedIconName, + category: 'system' as const, + }; + }) + ); + } catch (e) { + console.error('Failed to discover quick links:', e); + return []; + } +} + function getLocaleCandidates(): string[] { const set = new Set(); const preferredAppLanguage = loadSettings().appLanguage; @@ -1270,7 +1602,103 @@ async function openSettingsPane(identifier: string): Promise { // ─── Public API ───────────────────────────────────────────────────── +function assignUniversalDeeplinks(commands: CommandInfo[]): void { + for (const cmd of commands) { + if (!cmd.deeplink && cmd.id) { + cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; + } + } +} + +function applyRuntimeMetadataAndAliases(commands: CommandInfo[]): void { + try { + const loadedSettings = loadSettings(); + const commandMetadata = loadedSettings.commandMetadata || {}; + const commandAliases = loadedSettings.commandAliases || {}; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + applyStoredRuntimeCommandMetadata(commands, commandMetadata); + for (const cmd of commands) { + const alias = String(commandAliases[cmd.id] || '').trim(); + if (alias) { + cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); + } + } + } catch {} +} + +function publishCommandCache(commands: CommandInfo[]): CommandInfo[] { + assignUniversalDeeplinks(commands); + applyRuntimeMetadataAndAliases(commands); + + cachedCommands = commands; + cacheTimestamp = Date.now(); + staleCommandsFallback = commands; + saveCommandsDiskCache(commands); + + return cachedCommands; +} + +function cloneCommandForTargetedRefresh(command: CommandInfo): CommandInfo { + return { + ...command, + keywords: command.keywords ? [...command.keywords] : undefined, + commandArgumentDefinitions: command.commandArgumentDefinitions + ? command.commandArgumentDefinitions.map((arg) => ({ + ...arg, + data: arg.data ? arg.data.map((item) => ({ ...item })) : arg.data, + })) + : undefined, + }; +} + +function getExtensionInsertionIndex(commands: CommandInfo[]): number { + const existingExtensionIndex = commands.findIndex((command) => command.category === 'extension'); + if (existingExtensionIndex >= 0) return existingExtensionIndex; + + const scriptIndex = commands.findIndex((command) => command.category === 'script'); + if (scriptIndex >= 0) return scriptIndex; + + const quickLinkIndex = commands.findIndex((command) => isQuickLinkCommandId(command.id)); + if (quickLinkIndex >= 0) return quickLinkIndex; + + const systemIndex = commands.findIndex((command) => command.category === 'system'); + if (systemIndex >= 0) return systemIndex; + + return commands.length; +} + +function rebuildCommandsWithFreshExtensions( + baseCommands: CommandInfo[], + extensionCommands: CommandInfo[] +): CommandInfo[] { + const insertionIndex = getExtensionInsertionIndex(baseCommands); + const beforeExtensions: CommandInfo[] = []; + const afterExtensions: CommandInfo[] = []; + + baseCommands.forEach((command, index) => { + if (command.category === 'extension') return; + const cloned = cloneCommandForTargetedRefresh(command); + if (index < insertionIndex) { + beforeExtensions.push(cloned); + } else { + afterExtensions.push(cloned); + } + }); + + return [ + ...beforeExtensions, + ...extensionCommands.map(cloneCommandForTargetedRefresh), + ...afterExtensions, + ]; +} + async function discoverAndBuildCommands(): Promise { + commandDiscoveryStartCount++; + if (commandDiscoveryRunnerForTesting) { + return publishCommandCache(await commandDiscoveryRunnerForTesting()); + } + const t0 = Date.now(); console.log('Discovering applications and settings…'); @@ -1847,90 +2275,12 @@ async function discoverAndBuildCommands(): Promise { ]; // Installed community extensions - let extensionCommands: CommandInfo[] = []; - try { - extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({ - id: ext.id, - title: ext.title, - subtitle: ext.extensionTitle, - keywords: ext.keywords, - iconDataUrl: ext.iconDataUrl, - category: 'extension' as const, - path: `${ext.extName}/${ext.cmdName}`, - mode: ext.mode, - interval: ext.interval, - disabledByDefault: ext.disabledByDefault, - commandArgumentDefinitions: ext.commandArgumentDefinitions || [], - deeplink: ext.owner - ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` - : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, - })); - } catch (e) { - console.error('Failed to discover installed extensions:', e); - } + const extensionCommands = discoverExtensionCommandInfos(); // Raycast-compatible script commands - let scriptCommands: CommandInfo[] = []; - try { - scriptCommands = discoverScriptCommands().map((script) => ({ - id: script.id, - title: script.title, - subtitle: script.packageName, - keywords: script.keywords, - iconDataUrl: script.iconDataUrl, - iconEmoji: script.iconEmoji, - category: 'script' as const, - path: script.scriptPath, - mode: script.mode, - interval: script.interval, - needsConfirmation: script.needsConfirmation, - commandArgumentDefinitions: script.arguments.map((arg) => ({ - name: arg.name, - required: arg.required, - type: arg.type, - placeholder: arg.placeholder, - title: arg.placeholder, - data: arg.data, - })), - deeplink: script.slug - ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` - : undefined, - })); - } catch (e) { - console.error('Failed to discover script commands:', e); - } + const scriptCommands = discoverScriptCommandInfos(); - let quickLinkCommands: CommandInfo[] = []; - try { - const quickLinks = getAllQuickLinks(); - quickLinkCommands = await Promise.all( - quickLinks.map(async (quickLink) => { - const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); - let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); - - // Prefer real app icon for default quick-link icons so launcher search - // reflects the target application even when stored icon data is stale. - if (!resolvedIconName && quickLink.applicationPath) { - const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); - if (resolvedAppIconDataUrl) { - iconDataUrl = resolvedAppIconDataUrl; - } - } - - return { - id: getQuickLinkCommandId(quickLink.id), - title: quickLink.name, - subtitle: quickLink.applicationName || 'Quick Link', - keywords: buildQuickLinkKeywords(quickLink), - iconDataUrl, - iconName: iconDataUrl ? undefined : resolvedIconName, - category: 'system' as const, - }; - }) - ); - } catch (e) { - console.error('Failed to discover quick links:', e); - } + const quickLinkCommands = await discoverQuickLinkCommandInfos(); const allCommands = [...apps, ...settings, ...extensionCommands, ...scriptCommands, ...quickLinkCommands, ...systemCommands]; @@ -1974,47 +2324,13 @@ async function discoverAndBuildCommands(): Promise { delete cmd._bundlePath; } - // Assign a universal deeplink to any launcher command that doesn't already - // have one (extensions + scripts keep their owner/slug-based schemes above). - // This lets apps, settings, system, and quick-link commands be copied and - // re-invoked via `supercmd://commands/`. - for (const cmd of allCommands) { - if (!cmd.deeplink && cmd.id) { - cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; - } - } - - // Runtime metadata overlays (used by updateCommandMetadata and inline scripts). - try { - const loadedSettings = loadSettings(); - const commandMetadata = loadedSettings.commandMetadata || {}; - const commandAliases = loadedSettings.commandAliases || {}; - for (const cmd of allCommands) { - if (!(cmd.category === 'script' && cmd.mode !== 'inline')) { - const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); - if (subtitle) { - cmd.subtitle = subtitle; - } - } - const alias = String(commandAliases[cmd.id] || '').trim(); - if (alias) { - cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); - } - } - } catch {} - - cachedCommands = allCommands; - cacheTimestamp = Date.now(); - staleCommandsFallback = allCommands; + publishCommandCache(allCommands); console.log( `Discovered ${apps.length} apps, ${settings.length} settings panes, ${extensionCommands.length} extension commands, ${scriptCommands.length} script commands, ${quickLinkCommands.length} quick links in ${Date.now() - t0}ms` ); - // Persist to disk so the next startup can serve commands instantly. - saveCommandsDiskCache(allCommands); - - return cachedCommands; + return allCommands; } function ensureBackgroundRefreshForStaleCache(): void { @@ -2023,7 +2339,7 @@ function ensureBackgroundRefreshForStaleCache(): void { const now = Date.now(); if (now - lastStaleRefreshRequestAt < STALE_REFRESH_COOLDOWN_MS) return; lastStaleRefreshRequestAt = now; - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return cachedCommands || []; @@ -2038,12 +2354,40 @@ export async function refreshCommandsNow(): Promise { return inflightDiscovery; } - inflightDiscovery = discoverAndBuildCommands().finally(() => { + inflightDiscovery = startCommandDiscovery().finally(() => { inflightDiscovery = null; }); return inflightDiscovery; } +export async function refreshCommandsForExtensionChange(): Promise { + if (!cachedCommands && !staleCommandsFallback) { + return refreshCommandsNow(); + } + + if (inflightDiscovery) { + try { + await inflightDiscovery; + } catch (error) { + console.warn('[Commands] Inflight refresh failed before extension refresh:', error); + } + } + + const baseCommands = cachedCommands || staleCommandsFallback; + if (!baseCommands) { + return refreshCommandsNow(); + } + + const t0 = Date.now(); + const extensionCommands = discoverExtensionCommandInfos(); + const nextCommands = rebuildCommandsWithFreshExtensions(baseCommands, extensionCommands); + const refreshed = publishCommandCache(nextCommands); + console.log( + `[Commands] Refreshed ${extensionCommands.length} extension commands from cached app/settings base in ${Date.now() - t0}ms` + ); + return refreshed; +} + export async function getAvailableCommands(): Promise { const now = Date.now(); if (cachedCommands && now - cacheTimestamp < CACHE_TTL) { @@ -2062,7 +2406,7 @@ export async function getAvailableCommands(): Promise { // so the launcher never blocks on discovery after an invalidation event. if (staleCommandsFallback) { if (!inflightDiscovery) { - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return staleCommandsFallback || []; diff --git a/src/main/exec-command.ts b/src/main/exec-command.ts new file mode 100644 index 00000000..9475f9f5 --- /dev/null +++ b/src/main/exec-command.ts @@ -0,0 +1,157 @@ +import * as fs from 'fs'; +import { execFileSync as defaultExecFileSync, spawn as defaultSpawn } from 'child_process'; + +export type ExecCommandOptions = { + shell?: boolean | string; + input?: string; + env?: Record; + cwd?: string; +}; + +export type ExecCommandResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +type ExecCommandProcess = { + stdout?: { + on(event: 'data', listener: (data: Buffer) => void): void; + }; + stderr?: { + on(event: 'data', listener: (data: Buffer) => void): void; + }; + stdin?: { + write(input: string): void; + end(): void; + }; + on(event: 'close', listener: (code: number | null) => void): void; + on(event: 'error', listener: (err: Error) => void): void; + kill(): unknown; +}; + +type ExecFileSyncLike = ( + file: string, + args?: readonly string[], + options?: Record +) => string | Buffer; + +type ExecCommandDependencies = { + spawn?: (command: string, args: string[], options: Record) => ExecCommandProcess; + execFileSync?: ExecFileSyncLike; + existsSync?: (path: fs.PathLike) => boolean; + env?: NodeJS.ProcessEnv; + cwd?: () => string; + setTimeout?: typeof setTimeout; + clearTimeout?: typeof clearTimeout; +}; + +const EXEC_COMMAND_TIMEOUT_MS = 300000; + +function resolveExecutablePath(input: string, dependencies: Required>): string { + if (!input || typeof input !== 'string') return input; + if (!input.includes('/') && !input.includes('\\')) return input; + if (!input.startsWith('/')) return input; + if (dependencies.existsSync(input)) return input; + try { + const base = input.split('/').filter(Boolean).pop() || ''; + if (!base) return input; + const lookup = String( + dependencies.execFileSync( + '/bin/zsh', + ['-lc', `command -v -- ${JSON.stringify(base)} 2>/dev/null || true`], + { encoding: 'utf-8' } + ) + ).trim(); + if (lookup && dependencies.existsSync(lookup)) return lookup; + } catch {} + return input; +} + +export function runExecCommand( + command: string, + args: string[], + options?: ExecCommandOptions, + dependencies: ExecCommandDependencies = {} +): Promise { + const spawn = dependencies.spawn ?? defaultSpawn; + const execFileSync = dependencies.execFileSync ?? defaultExecFileSync; + const existsSync = dependencies.existsSync ?? fs.existsSync.bind(fs); + const env = dependencies.env ?? process.env; + const cwd = dependencies.cwd ?? process.cwd.bind(process); + const scheduleTimeout = dependencies.setTimeout ?? setTimeout; + const cancelTimeout = dependencies.clearTimeout ?? clearTimeout; + + return new Promise((resolve) => { + try { + const normalizedCommand = resolveExecutablePath(command, { execFileSync, existsSync }); + // Augment PATH so extensions can find brew, npm, nvm, etc. even when + // the app is launched from the Dock (where macOS strips the login PATH). + const extraPaths = [ + '/opt/homebrew/bin', '/opt/homebrew/sbin', + '/usr/local/bin', '/usr/local/sbin', + '/usr/bin', '/usr/sbin', '/bin', '/sbin', + ]; + const currentPath = (options?.env?.PATH ?? env.PATH ?? ''); + const augmentedPath = [ + ...extraPaths, + ...currentPath.split(':').filter(Boolean), + ].filter((v, i, a) => a.indexOf(v) === i).join(':'); + const spawnOptions: Record = { + shell: options?.shell ?? false, + env: { ...env, ...options?.env, PATH: augmentedPath }, + cwd: options?.cwd || cwd(), + }; + + const proc = options?.shell + ? spawn([normalizedCommand, ...args].join(' '), [], { ...spawnOptions, shell: true }) + : spawn(normalizedCommand, args, spawnOptions); + + let stdout = ''; + let stderr = ''; + let settled = false; + let timeout: ReturnType | null = null; + + const finish = (result: ExecCommandResult) => { + if (settled) return; + settled = true; + if (timeout) { + cancelTimeout(timeout); + timeout = null; + } + resolve(result); + }; + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + if (options?.input && proc.stdin) { + proc.stdin.write(options.input); + proc.stdin.end(); + } + + proc.on('close', (code: number | null) => { + finish({ stdout, stderr, exitCode: code ?? 0 }); + }); + + proc.on('error', (err: Error) => { + finish({ stdout, stderr: err.message, exitCode: 1 }); + }); + + // Timeout after 5 minutes - allows long-running commands (brew install, npm install, etc.) + timeout = scheduleTimeout(() => { + try { + proc.kill(); + } catch {} + finish({ stdout, stderr: stderr || 'Command timed out', exitCode: 124 }); + }, EXEC_COMMAND_TIMEOUT_MS); + } catch (e: any) { + resolve({ stdout: '', stderr: e?.message || 'Failed to execute command', exitCode: 1 }); + } + }); +} diff --git a/src/main/extension-registry.ts b/src/main/extension-registry.ts index 21cfc19a..43261b24 100644 --- a/src/main/extension-registry.ts +++ b/src/main/extension-registry.ts @@ -36,6 +36,13 @@ import { installDepsWithBun } from './bun-manager'; const execAsync = promisify(exec); +function invalidateExtensionRunnerCaches(): void { + try { + const runner = require('./extension-runner'); + runner.invalidateExtensionRunnerCaches?.(); + } catch {} +} + function shellQuoteSingle(value: string): string { return `'${String(value || '').replace(/'/g, `'\\''`)}'`; } @@ -1137,6 +1144,7 @@ async function installExtensionFromBundle( // Copy to extensions directory fs.cpSync(srcDir, installPath, { recursive: true }); + invalidateExtensionRunnerCaches(); // Cleanup backup if (backupPath && fs.existsSync(backupPath)) { @@ -1244,6 +1252,7 @@ async function installExtensionViaAPI(name: string): Promise { const { buildAllCommands } = require('./extension-runner'); const builtCount = await buildAllCommands(name); console.log(` Build: ${Date.now() - t1}ms. Extension "${name}" installed (${builtCount} commands) in ${Date.now() - t0}ms total`); + invalidateExtensionRunnerCaches(); } // Cleanup backup @@ -1355,6 +1364,7 @@ async function installExtensionViaGit(name: string): Promise { const { buildAllCommands } = require('./extension-runner'); const builtCount = await buildAllCommands(name); console.log(`Extension "${name}" installed (${builtCount} commands) at ${installPath}`); + invalidateExtensionRunnerCaches(); if (backupPath && fs.existsSync(backupPath)) { fs.rmSync(backupPath, { recursive: true, force: true }); } @@ -1535,6 +1545,7 @@ export async function uninstallExtension(name: string): Promise { try { fs.rmSync(installPath, { recursive: true, force: true }); + invalidateExtensionRunnerCaches(); console.log(`Extension "${name}" uninstalled.`); // Report uninstall to backend (fire-and-forget) diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 034462e5..3cf031d9 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -14,6 +14,7 @@ */ import { app } from 'electron'; +import { createHash } from 'crypto'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -111,6 +112,73 @@ interface InstalledExtensionSource { sourceRoot: string; } +interface FsPathSignature { + exists: boolean; + isFile: boolean; + isDirectory: boolean; + size: number; + mtimeMs: number; +} + +interface InstalledExtensionSourceSignature { + extName: string; + extPath: string; + sourceRoot: string; + extPathSignature: FsPathSignature; + packageJsonSignature: FsPathSignature; +} + +interface InstalledExtensionsSnapshot { + roots: string[]; + rootSignatures: FsPathSignature[]; + sources: InstalledExtensionSource[]; + sourceSignatures: InstalledExtensionSourceSignature[]; +} + +interface CachedManifest { + signature: FsPathSignature; + value: any; +} + +interface CachedTextFile { + signature: FsPathSignature; + value: string; +} + +let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null; +const _extensionManifestCache = new Map(); +const _extensionBundleCodeCache = new Map(); + +interface BuildFileSignature extends FsPathSignature { + path: string; + hash: string; +} + +interface ExtensionCommandBuildStamp { + name: string; + commandSignature: string; + entryFile: string; + outFile: string; + outputSignature: BuildFileSignature; + inputSignatures: BuildFileSignature[]; +} + +interface ExtensionBuildStamp { + version: number; + extName: string; + platform: string; + arch: string; + esbuildVersion: string; + external: string[]; + tsconfigRaw: string; + runtimeDeps: string[]; + configSignatures: BuildFileSignature[]; + commands: ExtensionCommandBuildStamp[]; +} + +const extensionBuildStampVersion = 1; +const extensionBuildStampFile = '.sc-build-stamp.json'; + function getManagedExtensionsDir(): string { const dir = path.join(app.getPath('userData'), 'extensions'); if (!fs.existsSync(dir)) { @@ -144,6 +212,166 @@ function normalizeExtensionName(name: string): string { return raw.replace(/^@/, '').replace(/[\\/]/g, '-'); } +function getPathSignature(filePath: string): FsPathSignature { + try { + const stat = fs.statSync(filePath); + return { + exists: true, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { + exists: false, + isFile: false, + isDirectory: false, + size: -1, + mtimeMs: -1, + }; + } +} + +function samePathSignature(a: FsPathSignature, b: FsPathSignature): boolean { + return ( + a.exists === b.exists && + a.isFile === b.isFile && + a.isDirectory === b.isDirectory && + a.size === b.size && + a.mtimeMs === b.mtimeMs + ); +} + +function getStoredBuildPath(extPath: string, filePath: string): string { + const normalized = path.resolve(filePath); + const relative = path.relative(extPath, normalized); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return `rel:${relative.split(path.sep).join('/')}`; + } + return `abs:${normalized}`; +} + +function resolveStoredBuildPath(extPath: string, storedPath: string): string { + if (storedPath.startsWith('rel:')) { + return path.join(extPath, ...storedPath.slice(4).split('/')); + } + if (storedPath.startsWith('abs:')) { + return storedPath.slice(4); + } + return storedPath; +} + +function hashFile(filePath: string): string { + try { + return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); + } catch { + return ''; + } +} + +function getBuildFileSignature(extPath: string, filePath: string): BuildFileSignature { + const signature = getPathSignature(filePath); + return { + path: getStoredBuildPath(extPath, filePath), + ...signature, + hash: signature.exists && signature.isFile ? hashFile(filePath) : '', + }; +} + +function sameBuildFileSignature(a: BuildFileSignature, b: BuildFileSignature): boolean { + return ( + a.path === b.path && + a.exists === b.exists && + a.isFile === b.isFile && + a.isDirectory === b.isDirectory && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.hash === b.hash + ); +} + +function sameBuildFileSignatures(a: BuildFileSignature[], b: BuildFileSignature[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameBuildFileSignature(a[i], b[i])) return false; + } + return true; +} + +function sameRootSnapshot( + roots: string[], + rootSignatures: FsPathSignature[], + snapshot: InstalledExtensionsSnapshot +): boolean { + if (roots.length !== snapshot.roots.length) return false; + if (rootSignatures.length !== snapshot.rootSignatures.length) return false; + for (let i = 0; i < roots.length; i++) { + if (roots[i] !== snapshot.roots[i]) return false; + if (!samePathSignature(rootSignatures[i], snapshot.rootSignatures[i])) return false; + } + return true; +} + +function getInstalledExtensionSourceSignature(source: InstalledExtensionSource): InstalledExtensionSourceSignature { + return { + extName: source.extName, + extPath: source.extPath, + sourceRoot: source.sourceRoot, + extPathSignature: getPathSignature(source.extPath), + packageJsonSignature: getPathSignature(path.join(source.extPath, 'package.json')), + }; +} + +function sameInstalledExtensionSourceSignature( + a: InstalledExtensionSourceSignature, + b: InstalledExtensionSourceSignature +): boolean { + return ( + a.extName === b.extName && + a.extPath === b.extPath && + a.sourceRoot === b.sourceRoot && + samePathSignature(a.extPathSignature, b.extPathSignature) && + samePathSignature(a.packageJsonSignature, b.packageJsonSignature) + ); +} + +function getInstalledExtensionSourceSignatures( + sources: InstalledExtensionSource[] +): InstalledExtensionSourceSignature[] { + return sources.map((source) => getInstalledExtensionSourceSignature(source)); +} + +function sameInstalledExtensionSourceSignatures( + a: InstalledExtensionSourceSignature[], + b: InstalledExtensionSourceSignature[] +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameInstalledExtensionSourceSignature(a[i], b[i])) return false; + } + return true; +} + +function readExtensionBuildStamp(buildDir: string): ExtensionBuildStamp | null { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(buildDir, extensionBuildStampFile), 'utf-8')); + if (parsed?.version !== extensionBuildStampVersion || !Array.isArray(parsed?.commands)) return null; + return parsed; + } catch { + return null; + } +} + +function writeExtensionBuildStamp(buildDir: string, stamp: ExtensionBuildStamp): void { + try { + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, extensionBuildStampFile), JSON.stringify(stamp, null, 2)); + } catch (error: any) { + console.warn('Failed to write extension build stamp:', error?.message || error); + } +} + function getConfiguredExtensionRoots(): string[] { const settingsPaths = Array.isArray(loadSettings().customExtensionFolders) ? loadSettings().customExtensionFolders @@ -162,7 +390,7 @@ function getConfiguredExtensionRoots(): string[] { return [...unique]; } -function collectInstalledExtensions(): InstalledExtensionSource[] { +function scanInstalledExtensions(roots: string[]): InstalledExtensionSource[] { const results: InstalledExtensionSource[] = []; const seen = new Set(); @@ -183,7 +411,7 @@ function collectInstalledExtensions(): InstalledExtensionSource[] { results.push({ extName, extPath, sourceRoot }); }; - for (const sourceRoot of getConfiguredExtensionRoots()) { + for (const sourceRoot of roots) { if (!fs.existsSync(sourceRoot)) continue; const sourceRootPkg = path.join(sourceRoot, 'package.json'); @@ -206,6 +434,27 @@ function collectInstalledExtensions(): InstalledExtensionSource[] { return results; } +function collectInstalledExtensions(): InstalledExtensionSource[] { + const roots = getConfiguredExtensionRoots(); + const rootSignatures = roots.map((root) => getPathSignature(root)); + + if (_installedExtensionsSnapshot && sameRootSnapshot(roots, rootSignatures, _installedExtensionsSnapshot)) { + const currentSourceSignatures = getInstalledExtensionSourceSignatures(_installedExtensionsSnapshot.sources); + if (sameInstalledExtensionSourceSignatures(currentSourceSignatures, _installedExtensionsSnapshot.sourceSignatures)) { + return _installedExtensionsSnapshot.sources; + } + } + + const sources = scanInstalledExtensions(roots); + _installedExtensionsSnapshot = { + roots, + rootSignatures, + sources, + sourceSignatures: getInstalledExtensionSourceSignatures(sources), + }; + return sources; +} + function resolveInstalledExtensionPath(extName: string): string | null { const normalized = normalizeExtensionName(extName); if (!normalized) return null; @@ -213,11 +462,60 @@ function resolveInstalledExtensionPath(extName: string): string | null { return match?.extPath || null; } +function readExtensionManifest(extPath: string): any | null { + const pkgPath = path.join(extPath, 'package.json'); + const signature = getPathSignature(pkgPath); + if (!signature.exists || !signature.isFile) return null; + + const cached = _extensionManifestCache.get(pkgPath); + if (cached && samePathSignature(cached.signature, signature)) { + return cached.value; + } + + const value = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + _extensionManifestCache.set(pkgPath, { signature, value }); + return value; +} + +function readExtensionBundleCode(outFile: string): string { + const signature = getPathSignature(outFile); + if (!signature.exists || !signature.isFile) return ''; + + const cached = _extensionBundleCodeCache.get(outFile); + if (cached && samePathSignature(cached.signature, signature)) { + return cached.value; + } + + const value = fs.readFileSync(outFile, 'utf-8'); + _extensionBundleCodeCache.set(outFile, { signature, value }); + return value; +} + +function invalidateExtensionStaticCacheForPath(extPath: string): void { + const normalizedExtPath = path.resolve(extPath); + _installedExtensionsSnapshot = null; + _extensionManifestCache.delete(path.join(normalizedExtPath, 'package.json')); + + const buildDir = path.join(normalizedExtPath, '.sc-build') + path.sep; + for (const bundlePath of _extensionBundleCodeCache.keys()) { + if (bundlePath.startsWith(buildDir)) { + _extensionBundleCodeCache.delete(bundlePath); + } + } +} + +export function invalidateExtensionRunnerCaches(): void { + _installedExtensionsSnapshot = null; + _extensionManifestCache.clear(); + _extensionBundleCodeCache.clear(); + _extensionIconCache.clear(); +} + // ─── Icon extraction ──────────────────────────────────────────────── // Session-level cache: absolute icon path → stable data URL string. // Prevents re-reading and re-encoding the same icon file on every getCommands() call. -const _extensionIconCache = new Map(); +const _extensionIconCache = new Map(); function resizeIconWithSips(inputPath: string): Buffer | null { const tmp = path.join(os.tmpdir(), `sc-icon-${Date.now()}-${Math.random().toString(36).slice(2)}.png`); @@ -241,10 +539,13 @@ function getExtensionIconDataUrl( ]; for (const p of candidates) { - if (!fs.existsSync(p)) continue; + const signature = getPathSignature(p); + if (!signature.exists || !signature.isFile) continue; const cached = _extensionIconCache.get(p); - if (cached !== undefined) return cached; + if (cached !== undefined && samePathSignature(cached.signature, signature)) { + return cached.value; + } try { const ext = path.extname(p).toLowerCase(); @@ -260,7 +561,7 @@ function getExtensionIconDataUrl( result = `data:image/png;base64,${finalData.toString('base64')}`; } - _extensionIconCache.set(p, result); + _extensionIconCache.set(p, { signature, value: result }); return result; } catch {} } @@ -310,11 +611,11 @@ export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] { const results: ExtensionCommandInfo[] = []; for (const source of collectInstalledExtensions()) { const extPath = source.extPath; - const pkgPath = path.join(extPath, 'package.json'); const extName = source.extName; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) continue; if (!isManifestPlatformCompatible(pkg)) continue; const iconDataUrl = getExtensionIconDataUrl( extPath, @@ -375,11 +676,11 @@ export function getInstalledExtensionsSettingsSchema(): InstalledExtensionSettin const results: InstalledExtensionSettingsSchema[] = []; for (const source of collectInstalledExtensions()) { const extPath = source.extPath; - const pkgPath = path.join(extPath, 'package.json'); const extName = source.extName; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) continue; if (!isManifestPlatformCompatible(pkg)) continue; const iconDataUrl = getExtensionIconDataUrl(extPath, pkg.icon || 'icon.png'); const ownerRaw = pkg.owner || pkg.author || ''; @@ -465,6 +766,43 @@ function extensionRequiresNodeModules(pkg: any): boolean { return getInstallableRuntimeDeps(pkg).length > 0; } +function createNativeSchemeExternalPlugin(): any { + return { + name: 'native-scheme-external', + setup(build: any) { + build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ + path: args.path, + external: true, + })); + }, + }; +} + +function getExtensionBuildExternals(manifestExternal: string[]): string[] { + return [ + 'react', + 'react-dom', + 'react-dom/*', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@raycast/api', + '@raycast/utils', + 're2', + 'better-sqlite3', + 'fsevents', + 'raycast-cross-extension', + 'node-fetch', + 'undici', + 'undici/*', + 'axios', + 'tar', + 'extract-zip', + 'sha256-file', + ...manifestExternal, + ...nodeBuiltins, + ]; +} + /** * Parse a tsconfig.json that may contain JSONC features (comments, trailing commas). * TypeScript itself accepts these, and many Raycast extensions ship them @@ -638,6 +976,151 @@ function resolveEntryFile(extPath: string, cmd: any): string | null { return null; } +function getEsbuildPackageVersion(): string { + try { + const pkgPath = require.resolve('esbuild/package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + return typeof pkg?.version === 'string' ? pkg.version : 'unknown'; + } catch { + return 'unknown'; + } +} + +function getExtensionConfigSignatures(extPath: string): BuildFileSignature[] { + return [ + 'package.json', + 'tsconfig.json', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lock', + 'bun.lockb', + ] + .map((fileName) => path.join(extPath, fileName)) + .filter((filePath) => fs.existsSync(filePath)) + .map((filePath) => getBuildFileSignature(extPath, filePath)); +} + +function createExtensionBuildContext( + extName: string, + extPath: string, + pkg: any, + manifestExternal: string[], + tsconfigRaw: string +): Omit { + return { + version: extensionBuildStampVersion, + extName, + platform: process.platform, + arch: process.arch, + esbuildVersion: getEsbuildPackageVersion(), + external: getExtensionBuildExternals(manifestExternal), + tsconfigRaw, + runtimeDeps: getInstallableRuntimeDeps(pkg), + configSignatures: getExtensionConfigSignatures(extPath), + }; +} + +function sameExtensionBuildContext( + stamp: ExtensionBuildStamp | null, + context: Omit +): stamp is ExtensionBuildStamp { + if (!stamp) return false; + return ( + stamp.version === context.version && + stamp.extName === context.extName && + stamp.platform === context.platform && + stamp.arch === context.arch && + stamp.esbuildVersion === context.esbuildVersion && + JSON.stringify(stamp.external) === JSON.stringify(context.external) && + stamp.tsconfigRaw === context.tsconfigRaw && + JSON.stringify(stamp.runtimeDeps) === JSON.stringify(context.runtimeDeps) && + sameBuildFileSignatures(stamp.configSignatures, context.configSignatures) + ); +} + +function commandBuildSignature(cmd: any): string { + return JSON.stringify(cmd || {}); +} + +function findCommandStamp( + stamp: ExtensionBuildStamp, + cmdName: string +): ExtensionCommandBuildStamp | null { + return stamp.commands.find((command) => command.name === cmdName) || null; +} + +function isCommandBuildUpToDate( + extPath: string, + commandStamp: ExtensionCommandBuildStamp | null, + cmd: any, + entryFile: string, + outFile: string +): commandStamp is ExtensionCommandBuildStamp { + if (!commandStamp) return false; + if (commandStamp.commandSignature !== commandBuildSignature(cmd)) return false; + if (commandStamp.entryFile !== getStoredBuildPath(extPath, entryFile)) return false; + if (commandStamp.outFile !== getStoredBuildPath(extPath, outFile)) return false; + if (!fs.existsSync(outFile)) return false; + + const outputSignature = getBuildFileSignature(extPath, outFile); + if (!sameBuildFileSignature(commandStamp.outputSignature, outputSignature)) return false; + + const currentInputSignatures = commandStamp.inputSignatures.map((input) => + getBuildFileSignature(extPath, resolveStoredBuildPath(extPath, input.path)) + ); + return sameBuildFileSignatures(commandStamp.inputSignatures, currentInputSignatures); +} + +function getMetafileInputsForOutput( + extPath: string, + metafile: any, + outFile: string +): string[] { + const outputs = metafile?.outputs && typeof metafile.outputs === 'object' + ? metafile.outputs + : {}; + const normalizedOutFile = path.resolve(outFile); + + for (const [outputPath, outputMeta] of Object.entries(outputs)) { + const resolvedOutput = path.isAbsolute(outputPath) + ? path.resolve(outputPath) + : path.resolve(extPath, outputPath); + if (resolvedOutput !== normalizedOutFile) continue; + const inputs = (outputMeta as any)?.inputs; + if (!inputs || typeof inputs !== 'object') return []; + return Object.keys(inputs).map((inputPath) => + path.isAbsolute(inputPath) ? inputPath : path.resolve(extPath, inputPath) + ); + } + + return []; +} + +function createCommandBuildStamp( + extPath: string, + cmd: any, + entryFile: string, + outFile: string, + metafile: any +): ExtensionCommandBuildStamp { + const inputFiles = new Set([ + entryFile, + ...getMetafileInputsForOutput(extPath, metafile, outFile), + ]); + return { + name: String(cmd.name), + commandSignature: commandBuildSignature(cmd), + entryFile: getStoredBuildPath(extPath, entryFile), + outFile: getStoredBuildPath(extPath, outFile), + outputSignature: getBuildFileSignature(extPath, outFile), + inputSignatures: [...inputFiles] + .map((filePath) => getBuildFileSignature(extPath, filePath)) + .sort((a, b) => a.path.localeCompare(b.path)), + }; +} + /** * Build ALL commands for an installed extension using esbuild. * Called at install time so the extension is ready to run instantly. @@ -660,11 +1143,15 @@ export async function buildAllCommands(extName: string, extPathOverride?: string return 0; } + invalidateExtensionStaticCacheForPath(extPath); + let commands: any[]; + let pkg: any; let requiresNodeModules = false; let manifestExternal: string[] = []; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + pkg = readExtensionManifest(extPath); + if (!pkg) return 0; if (!isManifestPlatformCompatible(pkg)) { console.warn(`Skipping build for incompatible extension ${extName}`); return 0; @@ -680,122 +1167,172 @@ export async function buildAllCommands(extName: string, extPathOverride?: string if (commands.length === 0) return 0; - const esbuild = requireEsbuild(); - const extNodeModules = path.join(extPath, 'node_modules'); - if (requiresNodeModules && !fs.existsSync(extNodeModules)) { - try { - const { installExtensionDeps } = require('./extension-registry'); - await installExtensionDeps(extPath); - } catch (e: any) { - console.error(`Failed to install dependencies for ${extName}:`, e?.message || e); - return 0; - } - if (!fs.existsSync(extNodeModules)) { - console.error(`Dependencies missing for ${extName}: ${extNodeModules} not found`); - return 0; - } - } const buildDir = getBuildDir(extPath); - // Avoid stale command bundles when extension source layout changes. - try { - fs.rmSync(buildDir, { recursive: true, force: true }); - } catch {} - fs.mkdirSync(buildDir, { recursive: true }); - let built = 0; + const tsconfigRaw = getEsbuildTsconfigRaw(extPath); + const buildContext = createExtensionBuildContext(extName, extPath, pkg, manifestExternal, tsconfigRaw); + const previousStamp = readExtensionBuildStamp(buildDir); + const matchingStamp = sameExtensionBuildContext(previousStamp, buildContext) ? previousStamp : null; + const buildableCommands: Array<{ cmd: any; entryFile: string; outFile: string }> = []; + const staleCommands: Array<{ cmd: any; entryFile: string; outFile: string }> = []; + const nextCommandStamps = new Map(); + let reusedPrebuilt = 0; + let skipped = 0; for (const cmd of commands) { if (!cmd.name) continue; if (!isCommandPlatformCompatible(cmd)) continue; + const outFile = path.join(buildDir, `${cmd.name}.js`); const entryFile = resolveEntryFile(extPath, cmd); if (!entryFile) { + if (fs.existsSync(outFile)) { + reusedPrebuilt++; + continue; + } console.warn(`No entry file for ${extName}/${cmd.name}, skipping`); continue; } - const outFile = path.join(buildDir, `${cmd.name}.js`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const buildableCommand = { cmd, entryFile, outFile }; + buildableCommands.push(buildableCommand); + + const commandStamp = matchingStamp + ? findCommandStamp(matchingStamp, String(cmd.name)) + : null; + if (isCommandBuildUpToDate(extPath, commandStamp, cmd, entryFile, outFile)) { + nextCommandStamps.set(String(cmd.name), commandStamp); + skipped++; + } else { + staleCommands.push(buildableCommand); + } + } + + if (buildableCommands.length === 0) { + if (reusedPrebuilt > 0) { + console.log(`Reused ${reusedPrebuilt}/${commands.length} pre-built commands for ${extName}`); + return reusedPrebuilt; + } + console.log(`Built 0/${commands.length} commands for ${extName}`); + return 0; + } + if (staleCommands.length === 0) { + const ready = skipped + reusedPrebuilt; + console.log(`Reused ${ready}/${commands.length} commands for ${extName}`); + return ready; + } + + const extNodeModules = path.join(extPath, 'node_modules'); + if (requiresNodeModules && !fs.existsSync(extNodeModules)) { try { - console.log(` Building ${extName}/${cmd.name}…`); - - await runEsbuildBuild( - esbuild, - { - entryPoints: [entryFile], - absWorkingDir: extPath, - bundle: true, - format: 'cjs', - platform: 'node', - outfile: outFile, - plugins: [ - // Mark swift:/rust: imports as external so fakeRequire can handle them at runtime - { - name: 'native-scheme-external', - setup(build: any) { - build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ - path: args.path, - external: true, - })); - }, - }, - ], - external: [ - // React — provided by the renderer at runtime - 'react', - 'react-dom', - 'react-dom/*', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - // Raycast — provided by our shim - '@raycast/api', - '@raycast/utils', - // Native C++ addons — cannot be bundled, we stub them at runtime - 're2', - 'better-sqlite3', - 'fsevents', - // Cross-extension calls — not supported, stubbed - 'raycast-cross-extension', - // Fetch libs — use runtime shims in renderer instead of bundling Node internals - 'node-fetch', - 'undici', - 'undici/*', - // HTTP / file-download / archive packages — must be kept external so our renderer - // shim can intercept them and route file I/O through the main process (which has - // real filesystem access). Bundling them inline breaks binary downloads because the - // browser renderer cannot do streaming file writes or archive extraction natively. - 'axios', - 'tar', - 'extract-zip', - 'sha256-file', - // Respect extension-defined externals from manifest - ...manifestExternal, - // Node.js built-ins — stubbed at runtime in the renderer - ...nodeBuiltins, - ], - nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], - target: 'es2020', - jsx: 'automatic', - jsxImportSource: 'react', - tsconfigRaw: getEsbuildTsconfigRaw(extPath), - define: { - 'process.env.NODE_ENV': '"production"', - 'global': 'globalThis', - }, - logLevel: 'warning', - }, - extPath, - `${extName}/${cmd.name}` - ); + const { installExtensionDeps } = require('./extension-registry'); + await installExtensionDeps(extPath); + } catch (e: any) { + console.error(`Failed to install dependencies for ${extName}:`, e?.message || e); + return skipped + reusedPrebuilt; + } + if (!fs.existsSync(extNodeModules)) { + console.error(`Dependencies missing for ${extName}: ${extNodeModules} not found`); + return skipped + reusedPrebuilt; + } + } + + const esbuild = requireEsbuild(); + const commonOptions = { + absWorkingDir: extPath, + bundle: true, + format: 'cjs', + platform: 'node', + plugins: [createNativeSchemeExternalPlugin()], + external: buildContext.external, + nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], + target: 'es2020', + jsx: 'automatic', + jsxImportSource: 'react', + tsconfigRaw, + define: { + 'process.env.NODE_ENV': '"production"', + 'global': 'globalThis', + }, + logLevel: 'warning', + metafile: true, + }; + + for (const { outFile } of staleCommands) { + try { + fs.rmSync(outFile, { force: true }); + } catch {} + } + let built = skipped + reusedPrebuilt; + try { + console.log(` Building ${staleCommands.length}/${buildableCommands.length} stale commands for ${extName}…`); + const entryPoints = Object.fromEntries( + staleCommands.map(({ cmd, entryFile }) => [String(cmd.name), entryFile]) + ); + const result = await runEsbuildBuild( + esbuild, + { + ...commonOptions, + entryPoints, + outdir: buildDir, + }, + extPath, + `${extName}/*` + ); + + for (const { cmd, entryFile, outFile } of staleCommands) { if (fs.existsSync(outFile)) { + nextCommandStamps.set( + String(cmd.name), + createCommandBuildStamp(extPath, cmd, entryFile, outFile, result?.metafile) + ); built++; } - } catch (e) { - console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); + } + } catch (batchError) { + console.warn( + ` Batched esbuild failed for ${extName}; falling back to per-command builds:`, + (batchError as any)?.message || batchError + ); + + built = skipped + reusedPrebuilt; + for (const { cmd, entryFile, outFile } of staleCommands) { + try { + console.log(` Building ${extName}/${cmd.name}…`); + + const result = await runEsbuildBuild( + esbuild, + { + ...commonOptions, + entryPoints: [entryFile], + outfile: outFile, + }, + extPath, + `${extName}/${cmd.name}` + ); + + if (fs.existsSync(outFile)) { + nextCommandStamps.set( + String(cmd.name), + createCommandBuildStamp(extPath, cmd, entryFile, outFile, result?.metafile) + ); + built++; + } + } catch (e) { + console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); + } } } + if (nextCommandStamps.size > 0) { + writeExtensionBuildStamp(buildDir, { + ...buildContext, + commands: [...nextCommandStamps.values()].sort((a, b) => a.name.localeCompare(b.name)), + }); + } + console.log(`Built ${built}/${commands.length} commands for ${extName}`); return built; } @@ -954,11 +1491,14 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom return false; } + invalidateExtensionStaticCacheForPath(extPath); + let cmd: any; let requiresNodeModules = false; let manifestExternal: string[] = []; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) return false; if (!isManifestPlatformCompatible(pkg)) { console.error(`buildSingleCommand: platform not compatible for ${extName}`); return false; @@ -1111,9 +1651,9 @@ async function runEsbuildBuild( options: any, extPath: string, label: string -): Promise { +): Promise { try { - await esbuild.build(options); + return await esbuild.build(options); } catch (error: any) { const missing = extractMissingBareImports(error); if (missing.length === 0) throw error; @@ -1129,7 +1669,7 @@ async function runEsbuildBuild( ); throw error; } - await esbuild.build(options); + return await esbuild.build(options); } } @@ -1172,8 +1712,7 @@ export async function getExtensionBundle( if (!fs.existsSync(outFile)) { let entryMissing = false; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); const cmd = (Array.isArray(pkg?.commands) ? pkg.commands : []).find((c: any) => c?.name === cmdName); if (cmd && !resolveEntryFile(extPath, cmd)) entryMissing = true; } catch {} @@ -1203,8 +1742,7 @@ export async function getExtensionBundle( if (!fs.existsSync(outFile)) { let diagnostic = ''; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); const commands = Array.isArray(pkg?.commands) ? pkg.commands : []; const cmd = commands.find((c: any) => c?.name === cmdName); const nodeModulesExists = fs.existsSync(path.join(extPath, 'node_modules')); @@ -1230,7 +1768,7 @@ export async function getExtensionBundle( } } - const code = fs.readFileSync(outFile, 'utf-8'); + const code = readExtensionBundleCode(outFile); if (!code) { const msg = `Pre-built bundle is empty: ${outFile}`; console.error(msg); @@ -1266,8 +1804,8 @@ export async function getExtensionBundle( }> = []; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) return null; if (!isManifestPlatformCompatible(pkg)) { return null; } diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..8467c02c 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -41,6 +41,7 @@ type IndexedEntry = { parentPath: string; normalizedName: string; normalizedPath: string; + normalizedTildePath: string; compactName: string; tokens: string[]; pathTokens: string[]; @@ -63,6 +64,7 @@ const MAX_QUERY_RESULTS = 5_000; const MAX_FILE_METADATA_STAT_RESULTS = 240; const MIN_REBUILD_GAP_MS = 45_000; const DEFAULT_REFRESH_INTERVAL_MS = 8 * 60_000; +const SAFETY_REBUILD_INTERVAL_MS = 6 * 60 * 60_000; const WATCH_EVENT_DEBOUNCE_MS = 500; const MAX_SPOTLIGHT_CANDIDATES = 10_000; const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400; @@ -132,6 +134,7 @@ const PROTECTED_TOP_LEVEL_SET = new Set( FILE_SEARCH_INDEX_PROTECTED_HOME_TOP_LEVEL_DIRECTORIES.map((name) => name.toLowerCase()) ); const EXCLUDED_FILE_EXTENSIONS = new Set(['.tmp', '.temp', '.log', '.cache', '.crdownload', '.download']); +const PATH_CANDIDATE_TERM_REGEX = /[a-z0-9]/; let activeIndex: IndexSnapshot | null = null; let rebuildPromise: Promise | null = null; @@ -143,10 +146,12 @@ let includeProtectedHomeRoots = false; let indexing = false; let lastIndexError: string | null = null; let lastBuildStartedAt = 0; +let lastSuccessfulFullRebuildAt = 0; let activeWatcher: fs.FSWatcher | null = null; let pendingWatchEvents: Set = new Set(); let watchDebounceTimer: NodeJS.Timeout | null = null; let watchedHomeDir = ''; +let lastWatcherError: string | null = null; type DirectoryQueueEntry = { scanPath: string; @@ -263,12 +268,13 @@ function addPrefixIndexValue(prefixToEntryIds: Map, key: strin function indexEntry( snapshot: IndexSnapshot, - entry: Omit + entry: Omit ): void { const normalizedName = normalizeSearchText(entry.name); if (!normalizedName) return; const normalizedPath = normalizePathSearchText(entry.path); if (!normalizedPath) return; + const normalizedTildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); const existingId = snapshot.pathToEntryId.get(entry.path); if (existingId !== undefined) { @@ -292,6 +298,7 @@ function indexEntry( ...entry, normalizedName, normalizedPath, + normalizedTildePath, compactName, tokens, pathTokens, @@ -589,6 +596,62 @@ function resolveCandidateIds(snapshot: IndexSnapshot, terms: string[]): number[] return intersectCandidates(indexedLists); } +function getPathLikeBoundaryTerms(needle: string): string[] { + const normalizedNeedle = normalizePathSearchText(needle); + if (!normalizedNeedle) return []; + + const terms: string[] = []; + let termStart = -1; + + const addTerm = (endIndex: number) => { + if (termStart <= 0) return; + const previousChar = normalizedNeedle[termStart - 1] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(previousChar)) return; + const term = normalizedNeedle.slice(termStart, endIndex); + if (term.length >= 2) terms.push(term); + }; + + for (let index = 0; index <= normalizedNeedle.length; index += 1) { + const char = normalizedNeedle[index] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(char)) { + if (termStart < 0) termStart = index; + continue; + } + + if (termStart >= 0) { + addTerm(index); + termStart = -1; + } + } + + return [...new Set(terms)]; +} + +function resolvePathLikeCandidateIds( + snapshot: IndexSnapshot, + rawNeedle: string, + expandedNeedle: string, + trimmedQuery: string +): number[] | null { + const isHomeTildeQuery = trimmedQuery === '~' || trimmedQuery.startsWith('~/') || trimmedQuery.startsWith('~\\'); + const terms = getPathLikeBoundaryTerms(rawNeedle); + if (terms.length === 0 && isHomeTildeQuery) { + terms.push(...getPathLikeBoundaryTerms(expandedNeedle)); + } + if (terms.length === 0) return null; + + let smallestBucket: number[] | null = null; + for (const term of terms) { + const key = term.slice(0, Math.min(MAX_PREFIX_LENGTH, term.length)); + const matches = snapshot.prefixToEntryIds.get(key); + if (!matches || matches.length === 0) return []; + if (!smallestBucket || matches.length < smallestBucket.length) { + smallestBucket = matches; + } + } + return smallestBucket ? [...smallestBucket] : null; +} + function resolveHomeDir(inputHomeDir?: string): string { const candidate = String(inputHomeDir || '').trim(); if (candidate) return path.resolve(candidate); @@ -632,7 +695,8 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { if (rebuildPromise) return rebuildPromise; const now = Date.now(); - if (now - lastBuildStartedAt < MIN_REBUILD_GAP_MS) return; + const bypassRebuildThrottle = reason === 'startup' || reason === 'watcher-error'; + if (!bypassRebuildThrottle && now - lastBuildStartedAt < MIN_REBUILD_GAP_MS) return; lastBuildStartedAt = now; rebuildPromise = (async () => { @@ -640,6 +704,7 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { try { const snapshot = await buildIndexSnapshot(configuredHomeDir); activeIndex = snapshot; + lastSuccessfulFullRebuildAt = snapshot.builtAt; lastIndexError = null; if (reason) { console.log( @@ -660,9 +725,32 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { export function requestFileSearchIndexRefresh(reason = 'manual'): void { if (rebuildPromise) return; + if (reason === 'interval' && canUseIncrementalIntervalRefresh()) { + flushPendingWatchEventsNow(); + return; + } void rebuildFileSearchIndex(reason); } +function canUseIncrementalIntervalRefresh(): boolean { + if (!activeIndex) return false; + if (!isFileSearchWatcherHealthy()) return false; + if (!lastSuccessfulFullRebuildAt) return false; + return Date.now() - lastSuccessfulFullRebuildAt < SAFETY_REBUILD_INTERVAL_MS; +} + +function isFileSearchWatcherHealthy(): boolean { + return Boolean(activeWatcher && watchedHomeDir === configuredHomeDir && !lastWatcherError); +} + +function flushPendingWatchEventsNow(): void { + if (watchDebounceTimer) { + clearTimeout(watchDebounceTimer); + watchDebounceTimer = null; + } + flushWatchEvents(); +} + function isWatchablePath(absolutePath: string): boolean { if (!configuredHomeDir) return false; if (!isPathWithinRoot(absolutePath, configuredHomeDir)) return false; @@ -692,7 +780,7 @@ function startFileSearchWatcher(): void { if (!configuredHomeDir) return; try { - activeWatcher = fs.watch( + const watcher = fs.watch( configuredHomeDir, { recursive: true, persistent: false }, (_eventType, filename) => { @@ -705,13 +793,27 @@ function startFileSearchWatcher(): void { } } ); + activeWatcher = watcher; watchedHomeDir = configuredHomeDir; - activeWatcher.on('error', (error) => { + lastWatcherError = null; + watcher.on('error', (error) => { console.warn('[FileIndex] watcher error:', error); + lastWatcherError = error instanceof Error ? error.message : String(error || 'Unknown watcher error'); + try { + watcher.close(); + } catch { + // ignore + } + if (activeWatcher === watcher) { + activeWatcher = null; + watchedHomeDir = ''; + } + requestFileSearchIndexRefresh('watcher-error'); }); console.log(`[FileIndex] watcher started on ${configuredHomeDir}`); } catch (error) { console.warn('[FileIndex] failed to start watcher:', error); + lastWatcherError = error instanceof Error ? error.message : String(error || 'Unknown watcher error'); activeWatcher = null; watchedHomeDir = ''; } @@ -796,28 +898,97 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } -function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { - const directIds = new Set(); - for (const deletedPath of deletePaths) { - const id = snapshot.pathToEntryId.get(deletedPath); - if (id !== undefined) directIds.add(id); +function hasDeletedPathAncestor(candidatePath: string, deletedPathSet: Set): boolean { + let currentPath = path.dirname(candidatePath); + while (currentPath && currentPath !== candidatePath) { + if (deletedPathSet.has(currentPath)) return true; + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; + } + return false; +} + +function normalizeDeletedPath(candidatePath: string): string | null { + const rawPath = String(candidatePath || ''); + if (!rawPath) return null; + return path.resolve(rawPath); +} + +function collapseNestedDeletedPaths(deletePaths: string[]): string[] { + const uniquePaths = new Set(); + for (const deletePath of deletePaths) { + const normalizedPath = normalizeDeletedPath(deletePath); + if (normalizedPath) uniquePaths.add(normalizedPath); } - const prefixes = deletePaths.map((p) => p + path.sep); - for (let i = 0; i < snapshot.entries.length; i += 1) { - const entry = snapshot.entries[i]; - if (entry.deleted) continue; - if (directIds.has(i)) { - entry.deleted = true; + const sortedPaths = [...uniquePaths].sort((a, b) => { + if (a.length !== b.length) return a.length - b.length; + return a.localeCompare(b); + }); + const collapsedPaths: string[] = []; + const collapsedPathSet = new Set(); + + for (const deletePath of sortedPaths) { + if (hasDeletedPathAncestor(deletePath, collapsedPathSet)) { continue; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { + collapsedPaths.push(deletePath); + collapsedPathSet.add(deletePath); + } + + return collapsedPaths; +} + +function getDescendantPathPrefix(rootPath: string): string { + return rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; +} + +function markDescendantEntriesDeleted(snapshot: IndexSnapshot, deletedRootPaths: string[]): void { + if (deletedRootPaths.length === 0) return; + + if (deletedRootPaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(deletedRootPaths[0]); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (entry.path.startsWith(descendantPrefix)) { entry.deleted = true; - break; } } + return; + } + + const deletedPathSet = new Set(deletedRootPaths); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + entry.deleted = true; + } + } +} + +function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + + const deletedRootPaths: string[] = []; + for (const deletedPath of collapsedDeletePaths) { + const id = snapshot.pathToEntryId.get(deletedPath); + if (id === undefined) { + deletedRootPaths.push(deletedPath); + continue; + } + + const entry = snapshot.entries[id]; + if (entry && !entry.deleted) { + entry.deleted = true; + } + if (entry?.isDirectory) { + deletedRootPaths.push(deletedPath); + } } + + markDescendantEntriesDeleted(snapshot, deletedRootPaths); } async function walkAddedDirectory(snapshot: IndexSnapshot, dirPath: string): Promise { @@ -882,6 +1053,54 @@ export function stopFileSearchIndexing(): void { stopFileSearchWatcher(); } +function resetFileSearchIndexForPerfHarness(options?: { + homeDir?: string; + includeProtectedHomeRoots?: boolean; +}): void { + stopFileSearchIndexing(); + activeIndex = null; + rebuildPromise = null; + configuredHomeDir = ''; + includeRoots = []; + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + includeProtectedHomeRoots = Boolean(options?.includeProtectedHomeRoots); + indexing = false; + lastIndexError = null; + lastBuildStartedAt = 0; + pendingWatchEvents.clear(); + if (options?.homeDir) { + ensureConfigured(options.homeDir); + } +} + +async function rebuildFileSearchIndexForPerfHarness(options: { + homeDir: string; + includeProtectedHomeRoots?: boolean; +}): Promise { + resetFileSearchIndexForPerfHarness(options); + if (includeRoots.length === 0) return; + + indexing = true; + try { + activeIndex = await buildIndexSnapshot(configuredHomeDir); + lastIndexError = null; + } catch (error) { + lastIndexError = error instanceof Error ? error.message : String(error || 'Unknown indexing error'); + throw error; + } finally { + indexing = false; + rebuildPromise = null; + lastBuildStartedAt = 0; + } +} + +async function applyFileSearchWatchEventBatchForPerfHarness(paths: string[]): Promise { + if (!activeIndex) { + throw new Error('File search perf harness requires an active index before applying watch events.'); + } + await applyWatchEventBatch(paths.map((candidatePath) => path.resolve(candidatePath))); +} + export async function searchIndexedFiles( rawQuery: string, options?: { limit?: number } @@ -907,13 +1126,16 @@ export async function searchIndexedFiles( const expandedNeedle = trimmedQuery.startsWith('~') && configuredHomeDir ? normalizePathSearchText(`${configuredHomeDir}${trimmedQuery.slice(1)}`) : rawNeedle; + const candidateIds = resolvePathLikeCandidateIds(snapshot, rawNeedle, expandedNeedle, trimmedQuery); + const candidateEntries = candidateIds === null + ? snapshot.entries + : candidateIds.map((entryId) => snapshot.entries[entryId]).filter(Boolean); const scored: Array<{ entry: IndexedEntry; score: number }> = []; - for (const entry of snapshot.entries) { + for (const entry of candidateEntries) { if (entry.deleted) continue; const pathIndex = entry.normalizedPath.indexOf(expandedNeedle); - const tildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); - const tildeIndex = tildePath.indexOf(rawNeedle); + const tildeIndex = entry.normalizedTildePath.indexOf(rawNeedle); const matchIndex = pathIndex >= 0 ? pathIndex : tildeIndex; if (matchIndex < 0) continue; @@ -1059,6 +1281,7 @@ export async function searchIndexedFiles( parentPath: path.dirname(candidatePath), normalizedName, normalizedPath: normalizePathSearchText(candidatePath), + normalizedTildePath: normalizePathSearchText(asTildePath(candidatePath, configuredHomeDir)), compactName: normalizedName.replace(/\s+/g, ''), tokens: tokenizeSearchText(candidateName), pathTokens: tokenizeSearchText(candidatePath), @@ -1096,3 +1319,9 @@ export async function searchIndexedFiles( return merged; } + +export const __fileSearchIndexPerfHarness = { + reset: resetFileSearchIndexForPerfHarness, + rebuild: rebuildFileSearchIndexForPerfHarness, + applyWatchEventBatch: applyFileSearchWatchEventBatchForPerfHarness, +}; diff --git a/src/main/http-response-decode.ts b/src/main/http-response-decode.ts new file mode 100644 index 00000000..63d0732d --- /dev/null +++ b/src/main/http-response-decode.ts @@ -0,0 +1,38 @@ +import * as zlib from 'zlib'; + +function zlibDecode( + decoder: (input: Buffer, callback: (error: Error | null, result: Buffer) => void) => void, + bodyBuffer: Buffer +): Promise { + return new Promise((resolve, reject) => { + decoder(bodyBuffer, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); +} + +export async function decodeHttpResponseBodyBuffer( + bodyBuffer: Buffer, + contentEncoding: string +): Promise { + const normalizedEncoding = String(contentEncoding || '').toLowerCase(); + try { + if (normalizedEncoding.includes('br')) { + return await zlibDecode(zlib.brotliDecompress, bodyBuffer); + } + if (normalizedEncoding.includes('gzip')) { + return await zlibDecode(zlib.gunzip, bodyBuffer); + } + if (normalizedEncoding.includes('deflate')) { + return await zlibDecode(zlib.inflate, bodyBuffer); + } + } catch { + // If decompression fails, keep raw buffer to avoid hard-failing requests. + return bodyBuffer; + } + return bodyBuffer; +} diff --git a/src/main/icon-ipc-cache.ts b/src/main/icon-ipc-cache.ts new file mode 100644 index 00000000..20dfcfd0 --- /dev/null +++ b/src/main/icon-ipc-cache.ts @@ -0,0 +1,201 @@ +import * as path from 'path'; + +export type FileIconSizeBucket = 'small' | 'normal' | 'large'; + +export type FileIconImageLike = { + isEmpty(): boolean; + resize(options: { width: number; height: number }): { toDataURL(): string }; +}; + +export type FileIconProvider = ( + filePath: string, + options: { size: FileIconSizeBucket }, +) => Promise; + +export type AppIconResolver = (appPath: string, size: number) => string | null; + +export const FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES = 512; +export const APP_ICON_DATA_URL_CACHE_MAX_ENTRIES = 256; + +export class BoundedIconLruCache { + private readonly entries = new Map(); + + constructor(private readonly maxEntries: number) {} + + get size(): number { + return this.entries.size; + } + + get(key: string): Value | undefined { + if (!this.entries.has(key)) return undefined; + const value = this.entries.get(key) as Value; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Value): void { + if (this.maxEntries <= 0) return; + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } + + keys(): string[] { + return Array.from(this.entries.keys()); + } +} + +export function normalizeIconLogicalSize(size: unknown, fallback: number): number { + const numeric = typeof size === 'number' ? size : Number(size); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.max(1, Math.round(numeric)); +} + +export function getFileIconSizeBucket(size: number): FileIconSizeBucket { + if (size <= 16) return 'small'; + if (size >= 64) return 'large'; + return 'normal'; +} + +export function normalizeIconCachePath(filePath: string): string { + const raw = String(filePath || ''); + if (!raw) return ''; + return path.resolve(raw); +} + +export function buildFileIconCacheKey(filePath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 20); + return [ + normalizeIconCachePath(filePath), + String(logicalSize), + getFileIconSizeBucket(logicalSize), + ].join('\0'); +} + +export function buildAppIconCacheKey(appPath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 32); + return [normalizeIconCachePath(appPath), String(logicalSize)].join('\0'); +} + +export function createFileIconDataUrlCache(options: { + getFileIcon: FileIconProvider; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + async function resolve(filePath: string, size: unknown = 20): Promise { + const logicalSize = normalizeIconLogicalSize(size, 20); + const bucket = getFileIconSizeBucket(logicalSize); + const key = buildFileIconCacheKey(filePath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(async () => { + const icon = await options.getFileIcon(filePath, { size: bucket }); + if (!icon || icon.isEmpty()) return null; + const dataUrl = icon.resize({ width: logicalSize, height: logicalSize }).toDataURL(); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + }) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} + +export function createAppIconDataUrlCache(options: { + resolveAppIconDataUrl: AppIconResolver; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + function resolveFromSource(appPath: string, logicalSize: number, key: string): string | null { + try { + const dataUrl = options.resolveAppIconDataUrl(appPath, logicalSize); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + } catch { + return null; + } + } + + function resolveSync(appPath: string, size: unknown = 32): string | null { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + return resolveFromSource(appPath, logicalSize, key); + } + + async function resolve(appPath: string, size: unknown = 32): Promise { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(() => resolveFromSource(appPath, logicalSize, key)) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + resolveSync, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} diff --git a/src/main/local-model-status-probe.ts b/src/main/local-model-status-probe.ts new file mode 100644 index 00000000..767d8f15 --- /dev/null +++ b/src/main/local-model-status-probe.ts @@ -0,0 +1,60 @@ +export const LOCAL_ASR_HELPER_STATUS_CACHE_TTL_MS = 5_000; + +export type LocalAsrHelperStatus = { + state: string; +}; + +export type LocalAsrHelperStatusProbeOptions = { + forceRefresh?: boolean; +}; + +export type LocalAsrHelperStatusProbeCache = { + getStatus: (options?: LocalAsrHelperStatusProbeOptions) => TStatus; + rememberStatus: (status: TStatus) => TStatus; + clear: () => void; +}; + +type CreateLocalAsrHelperStatusProbeCacheOptions = { + readStatus: () => TStatus; + ttlMs?: number; + now?: () => number; +}; + +export function isCacheableLocalAsrHelperStatus(status: LocalAsrHelperStatus | null | undefined): boolean { + return status?.state === 'downloaded' || status?.state === 'not-downloaded'; +} + +export function createLocalAsrHelperStatusProbeCache({ + readStatus, + ttlMs = LOCAL_ASR_HELPER_STATUS_CACHE_TTL_MS, + now = Date.now, +}: CreateLocalAsrHelperStatusProbeCacheOptions): LocalAsrHelperStatusProbeCache { + let cachedStatus: TStatus | null = null; + let cacheExpiresAt = 0; + + function rememberStatus(status: TStatus): TStatus { + if (isCacheableLocalAsrHelperStatus(status)) { + cachedStatus = status; + cacheExpiresAt = now() + ttlMs; + } else { + cachedStatus = null; + cacheExpiresAt = 0; + } + return status; + } + + return { + getStatus(options = {}) { + const currentTime = now(); + if (!options.forceRefresh && cachedStatus && currentTime < cacheExpiresAt) { + return cachedStatus; + } + return rememberStatus(readStatus()); + }, + rememberStatus, + clear() { + cachedStatus = null; + cacheExpiresAt = 0; + }, + }; +} diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..f19d4aa5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,8 +18,10 @@ 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 { createLocalAsrHelperStatusProbeCache } from './local-model-status-probe'; +import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow, applyCommandMetadataUpdate, refreshCommandsForExtensionChange } from './commands'; import { loadSettings, saveSettings, @@ -44,6 +46,12 @@ import { import type { AppSettings, BrowserProfileSetting, BrowserProfileFilters, BrowserProfileFilterKind, RelocateMode } from './settings-store'; import { recordRootSearchLaunchInState, type RootSearchRankingState } from '../shared/root-search-ranking-state'; import { streamAI, streamAIChat, isAIAvailable, transcribeAudio } from './ai-provider'; +import { + createAIStreamIpcCoalescer, + forwardAIStreamChunksToIpc, + type AIStreamIpcCoalescer, +} from './ai-stream-ipc'; +import { transcribeWhisperAudioFile } from './whisper-transcribe-file'; import { scanAppRemnants } from './app-uninstaller'; import * as soulverCalculator from './soulver-calculator'; import { addMemory, buildMemoryContextSystemPrompt } from './memory'; @@ -68,6 +76,7 @@ import { mergeAiChatSnapshot, upsertAiChatConversation, } from './ai-chat-store'; +import { decodeHttpResponseBodyBuffer } from './http-response-decode'; import { getExtensionPreferences, getExtensionPreferencesSnapshot, @@ -86,6 +95,10 @@ import { evaluateRendererCrash, RENDERER_RECOVERY_DELAY_MS, } from './renderer-recovery'; +import { + createAppIconDataUrlCache, + createFileIconDataUrlCache, +} from './icon-ipc-cache'; import { startClipboardMonitor, stopClipboardMonitor, @@ -100,6 +113,8 @@ import { togglePinClipboardItem, moveClipboardPinnedItem, pruneClipboardHistoryOlderThan, + flushClipboardHistoryWrites, + hasPendingClipboardHistoryWrites, } from './clipboard-manager'; import { initSnippetStore, @@ -193,6 +208,8 @@ import { exportNoteToFile, exportNotesToFile, importNotesFromFile, + flushNotesToDisk, + hasPendingNotesSave, } from './notes-store'; import { initCanvasStore, @@ -204,20 +221,41 @@ import { deleteCanvas, duplicateCanvas, togglePinCanvas, - getScene, + getSceneAsync, saveScene, saveThumbnail, - getThumbnail, + getThumbnailAsync, exportCanvas, isCanvasLibInstalled, getCanvasLibDir, } from './canvas-store'; +import { serveCanvasLibAssetFromFile } from './sc-asset-protocol'; import { type RaycastImportProgress, executeRaycastConfigImport, importRaycastConfigFromFile, previewRaycastConfigImport, } from './raycast-config-import'; +import { runExecCommand, type ExecCommandOptions } from './exec-command'; +import { + createCachedMenuBarNativeImage, + createMenuBarNativeImageCache, +} from './menubar-native-image-cache'; +import { + createMenuBarNativeUpdateState, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, + type MenuBarNativeUpdateState, +} from './menubar-native-update-cache'; import { initialize as initAptabase, trackEvent } from "@aptabase/electron/main"; @@ -255,6 +293,7 @@ type ParakeetModelStatus = { }; let parakeetModelStatus: ParakeetModelStatus | null = null; let parakeetModelEnsurePromise: Promise | null = null; +let parakeetModelStatusProbeCache: ReturnType> | null = null; // Persistent serve-mode process for fast transcription (models stay loaded in memory) let parakeetServerProcess: any = null; // ChildProcess @@ -264,14 +303,15 @@ let parakeetServerBuffer = ''; type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let parakeetPendingRequest: PendingParakeetRequest | null = null; -function killParakeetServer(): void { - if (parakeetServerProcess) { +function killParakeetServer(processToKill: any = parakeetServerProcess): void { + if (processToKill) { try { - parakeetServerProcess.stdin?.write('{"command":"exit"}\n'); - parakeetServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - parakeetServerProcess = null; } + if (processToKill && parakeetServerProcess !== processToKill) return; + parakeetServerProcess = null; parakeetServerReady = false; parakeetServerStarting = null; parakeetServerBuffer = ''; @@ -287,7 +327,7 @@ function ensureParakeetServer(): Promise { } if (parakeetServerStarting) return parakeetServerStarting; - parakeetServerStarting = (async () => { + const startingPromise = (async () => { killParakeetServer(); const binaryPath = getParakeetTranscriberBinaryPath(); if (!fs.existsSync(binaryPath)) { @@ -305,6 +345,7 @@ function ensureParakeetServer(): Promise { }); child.on('exit', (code: number | null) => { + if (parakeetServerProcess !== child) return; console.log(`[Parakeet] Server process exited with code ${code}`); parakeetServerReady = false; parakeetServerProcess = null; @@ -316,6 +357,7 @@ function ensureParakeetServer(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { + if (parakeetServerProcess !== child) return; parakeetServerBuffer += chunk.toString(); const lines = parakeetServerBuffer.split('\n'); parakeetServerBuffer = lines.pop() || ''; @@ -345,28 +387,39 @@ function ensureParakeetServer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (parakeetServerProcess !== child) return; reject(new Error('Parakeet server startup timed out (120s)')); - killParakeetServer(); + killParakeetServer(child); }, 120_000); const checkReady = setInterval(() => { + if (parakeetServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Parakeet server startup superseded')); + return; + } if (parakeetServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!parakeetServerProcess || parakeetServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Parakeet server process died during startup')); } }, 50); }); - - parakeetServerStarting = null; })(); - return parakeetServerStarting; + parakeetServerStarting = startingPromise; + startingPromise.then( + () => { if (parakeetServerStarting === startingPromise) parakeetServerStarting = null; }, + () => { if (parakeetServerStarting === startingPromise) parakeetServerStarting = null; } + ); + return startingPromise; } function sendParakeetRequest(request: Record): Promise { @@ -393,15 +446,7 @@ function getParakeetTranscriberBinaryPath(): string { return getNativeBinaryPath('parakeet-transcriber'); } -function getParakeetModelStatus(): ParakeetModelStatus { - if (parakeetModelStatus?.state === 'downloading') { - return { ...parakeetModelStatus }; - } - if (parakeetModelStatus?.state === 'error') { - return { ...parakeetModelStatus }; - } - - // Ask the binary for the real status +function readParakeetModelStatusFromHelper(): ParakeetModelStatus { const binaryPath = getParakeetTranscriberBinaryPath(); try { if (!fs.existsSync(binaryPath)) { @@ -446,9 +491,36 @@ function getParakeetModelStatus(): ParakeetModelStatus { return parakeetModelStatus; } +function getParakeetModelStatusProbeCache(): ReturnType> { + if (!parakeetModelStatusProbeCache) { + parakeetModelStatusProbeCache = createLocalAsrHelperStatusProbeCache({ + readStatus: readParakeetModelStatusFromHelper, + }); + } + return parakeetModelStatusProbeCache; +} + +function rememberParakeetModelStatus(status: ParakeetModelStatus): ParakeetModelStatus { + parakeetModelStatus = status; + getParakeetModelStatusProbeCache().rememberStatus(status); + return status; +} + +function getParakeetModelStatus(options: { forceRefresh?: boolean } = {}): ParakeetModelStatus { + if (parakeetModelStatus?.state === 'downloading') { + return { ...parakeetModelStatus }; + } + if (parakeetModelStatus?.state === 'error') { + return { ...parakeetModelStatus }; + } + + parakeetModelStatus = getParakeetModelStatusProbeCache().getStatus(options); + return parakeetModelStatus; +} + async function ensureParakeetModelDownloaded(): Promise { // Check if already downloaded - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state === 'downloaded' && status.path) { return status.path; } @@ -522,12 +594,12 @@ async function ensureParakeetModelDownloaded(): Promise { }); }); - parakeetModelStatus = { + rememberParakeetModelStatus({ state: 'downloaded', modelName: 'parakeet-tdt-0.6b-v3', path: modelPath, progress: 1, - }; + }); console.log(`[Parakeet] Models ready at ${modelPath}`); return modelPath; } catch (error) { @@ -573,7 +645,7 @@ async function transcribeAudioWithParakeet(opts: { language?: string; mimeType?: string; }): Promise { - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state === 'downloading') { throw new Error('Parakeet models are still downloading. Finish setup from onboarding or Settings -> AI -> SuperCmd Whisper.'); } @@ -611,6 +683,7 @@ type Qwen3ModelStatus = { }; let qwen3ModelStatus: Qwen3ModelStatus | null = null; let qwen3ModelEnsurePromise: Promise | null = null; +let qwen3ModelStatusProbeCache: ReturnType> | null = null; let qwen3ServerProcess: any = null; let qwen3ServerReady = false; @@ -619,14 +692,15 @@ let qwen3ServerBuffer = ''; type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void }; let qwen3PendingRequest: PendingQwen3Request | null = null; -function killQwen3Server(): void { - if (qwen3ServerProcess) { +function killQwen3Server(processToKill: any = qwen3ServerProcess): void { + if (processToKill) { try { - qwen3ServerProcess.stdin?.write('{"command":"exit"}\n'); - qwen3ServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - qwen3ServerProcess = null; } + if (processToKill && qwen3ServerProcess !== processToKill) return; + qwen3ServerProcess = null; qwen3ServerReady = false; qwen3ServerStarting = null; qwen3ServerBuffer = ''; @@ -642,7 +716,7 @@ function ensureQwen3Server(): Promise { } if (qwen3ServerStarting) return qwen3ServerStarting; - qwen3ServerStarting = (async () => { + const startingPromise = (async () => { killQwen3Server(); const binaryPath = getParakeetTranscriberBinaryPath(); if (!fs.existsSync(binaryPath)) { @@ -660,6 +734,7 @@ function ensureQwen3Server(): Promise { }); child.on('exit', (code: number | null) => { + if (qwen3ServerProcess !== child) return; console.log(`[Qwen3] Server process exited with code ${code}`); qwen3ServerReady = false; qwen3ServerProcess = null; @@ -671,6 +746,7 @@ function ensureQwen3Server(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { + if (qwen3ServerProcess !== child) return; qwen3ServerBuffer += chunk.toString(); const lines = qwen3ServerBuffer.split('\n'); qwen3ServerBuffer = lines.pop() || ''; @@ -699,28 +775,39 @@ function ensureQwen3Server(): Promise { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (qwen3ServerProcess !== child) return; reject(new Error('Qwen3 server startup timed out (120s)')); - killQwen3Server(); + killQwen3Server(child); }, 120_000); const checkReady = setInterval(() => { + if (qwen3ServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Qwen3 server startup superseded')); + return; + } if (qwen3ServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!qwen3ServerProcess || qwen3ServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Qwen3 server process died during startup')); } }, 50); }); - - qwen3ServerStarting = null; })(); - return qwen3ServerStarting; + qwen3ServerStarting = startingPromise; + startingPromise.then( + () => { if (qwen3ServerStarting === startingPromise) qwen3ServerStarting = null; }, + () => { if (qwen3ServerStarting === startingPromise) qwen3ServerStarting = null; } + ); + return startingPromise; } function sendQwen3Request(request: Record): Promise { @@ -743,10 +830,7 @@ function sendQwen3Request(request: Record): Promise { }); } -function getQwen3ModelStatus(): Qwen3ModelStatus { - if (qwen3ModelStatus?.state === 'downloading') return { ...qwen3ModelStatus }; - if (qwen3ModelStatus?.state === 'error') return { ...qwen3ModelStatus }; - +function readQwen3ModelStatusFromHelper(): Qwen3ModelStatus { const binaryPath = getParakeetTranscriberBinaryPath(); try { if (!fs.existsSync(binaryPath)) { @@ -771,8 +855,31 @@ function getQwen3ModelStatus(): Qwen3ModelStatus { return qwen3ModelStatus; } +function getQwen3ModelStatusProbeCache(): ReturnType> { + if (!qwen3ModelStatusProbeCache) { + qwen3ModelStatusProbeCache = createLocalAsrHelperStatusProbeCache({ + readStatus: readQwen3ModelStatusFromHelper, + }); + } + return qwen3ModelStatusProbeCache; +} + +function rememberQwen3ModelStatus(status: Qwen3ModelStatus): Qwen3ModelStatus { + qwen3ModelStatus = status; + getQwen3ModelStatusProbeCache().rememberStatus(status); + return status; +} + +function getQwen3ModelStatus(options: { forceRefresh?: boolean } = {}): Qwen3ModelStatus { + if (qwen3ModelStatus?.state === 'downloading') return { ...qwen3ModelStatus }; + if (qwen3ModelStatus?.state === 'error') return { ...qwen3ModelStatus }; + + qwen3ModelStatus = getQwen3ModelStatusProbeCache().getStatus(options); + return qwen3ModelStatus; +} + async function ensureQwen3ModelDownloaded(): Promise { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state === 'downloaded' && status.path) return status.path; if (qwen3ModelEnsurePromise) return await qwen3ModelEnsurePromise; @@ -816,7 +923,7 @@ async function ensureQwen3ModelDownloaded(): Promise { }); }); - qwen3ModelStatus = { state: 'downloaded', modelName: 'qwen3-asr-0.6b', path: modelPath, progress: 1 }; + rememberQwen3ModelStatus({ state: 'downloaded', modelName: 'qwen3-asr-0.6b', path: modelPath, progress: 1 }); console.log(`[Qwen3] Models ready at ${modelPath}`); return modelPath; } catch (error) { @@ -835,7 +942,7 @@ async function transcribeAudioWithQwen3(opts: { language?: string; mimeType?: string; }): Promise { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state === 'downloading') throw new Error('Qwen3 models are still downloading.'); if (status.state !== 'downloaded') throw new Error('Qwen3 models have not been downloaded yet. Download them from Settings -> AI -> SuperCmd Whisper.'); @@ -1153,14 +1260,15 @@ let whisperCppServerBuffer = ''; type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let whisperCppPendingRequest: PendingWhisperCppRequest | null = null; -function killWhisperCppServer(): void { - if (whisperCppServerProcess) { +function killWhisperCppServer(processToKill: any = whisperCppServerProcess): void { + if (processToKill) { try { - whisperCppServerProcess.stdin?.write('{"command":"exit"}\n'); - whisperCppServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - whisperCppServerProcess = null; } + if (processToKill && whisperCppServerProcess !== processToKill) return; + whisperCppServerProcess = null; whisperCppServerReady = false; whisperCppServerStarting = null; whisperCppServerBuffer = ''; @@ -1176,7 +1284,7 @@ function ensureWhisperCppServer(): Promise { } if (whisperCppServerStarting) return whisperCppServerStarting; - whisperCppServerStarting = (async () => { + const startingPromise = (async () => { killWhisperCppServer(); const binaryPath = ensureWhisperCppTranscriberBinary(); const modelStatus = getWhisperCppModelStatus(); @@ -1191,6 +1299,7 @@ function ensureWhisperCppServer(): Promise { whisperCppServerProcess = child; child.on('exit', (code: number | null) => { + if (whisperCppServerProcess !== child) return; console.log(`[Whisper][whisper.cpp] Server process exited with code ${code}`); whisperCppServerReady = false; whisperCppServerProcess = null; @@ -1202,6 +1311,7 @@ function ensureWhisperCppServer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { + if (whisperCppServerProcess !== child) return; whisperCppServerBuffer += chunk.toString(); const lines = whisperCppServerBuffer.split('\n'); whisperCppServerBuffer = lines.pop() || ''; @@ -1236,28 +1346,39 @@ function ensureWhisperCppServer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (whisperCppServerProcess !== child) return; reject(new Error('Whisper.cpp server startup timed out (60s)')); - killWhisperCppServer(); + killWhisperCppServer(child); }, 60_000); const checkReady = setInterval(() => { + if (whisperCppServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Whisper.cpp server startup superseded')); + return; + } if (whisperCppServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!whisperCppServerProcess || whisperCppServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Whisper.cpp server process died during startup')); } }, 50); }); - - whisperCppServerStarting = null; })(); - return whisperCppServerStarting; + whisperCppServerStarting = startingPromise; + startingPromise.then( + () => { if (whisperCppServerStarting === startingPromise) whisperCppServerStarting = null; }, + () => { if (whisperCppServerStarting === startingPromise) whisperCppServerStarting = null; } + ); + return startingPromise; } function sendWhisperCppRequest(request: Record): Promise { @@ -1420,14 +1541,15 @@ function getAudioCapturerBinaryPath(): string { return getNativeBinaryPath('audio-capturer'); } -function killAudioCapturer(): void { - if (audioCapturerProcess) { +function killAudioCapturer(processToKill: any = audioCapturerProcess): void { + if (processToKill) { try { - audioCapturerProcess.stdin?.write('{"command":"exit"}\n'); - audioCapturerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - audioCapturerProcess = null; } + if (processToKill && audioCapturerProcess !== processToKill) return; + audioCapturerProcess = null; audioCapturerReady = false; audioCapturerStarting = null; audioCapturerBuffer = ''; @@ -1499,7 +1621,7 @@ function warmAudioCapturer(): Promise { } if (audioCapturerStarting) return audioCapturerStarting; - audioCapturerStarting = (async () => { + const startingPromise = (async () => { killAudioCapturer(); const binaryPath = ensureAudioCapturerBinary(); @@ -1510,6 +1632,7 @@ function warmAudioCapturer(): Promise { audioCapturerProcess = child; child.on('exit', (code: number | null) => { + if (audioCapturerProcess !== child) return; console.log(`[AudioCapturer] Process exited with code ${code}`); audioCapturerReady = false; audioCapturerProcess = null; @@ -1522,6 +1645,7 @@ function warmAudioCapturer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { + if (audioCapturerProcess !== child) return; audioCapturerBuffer += chunk.toString(); const lines = audioCapturerBuffer.split('\n'); audioCapturerBuffer = lines.pop() || ''; @@ -1580,28 +1704,39 @@ function warmAudioCapturer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (audioCapturerProcess !== child) return; reject(new Error('Audio capturer warmup timed out (30s)')); - killAudioCapturer(); + killAudioCapturer(child); }, 30_000); const checkReady = setInterval(() => { + if (audioCapturerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Audio capturer warmup superseded')); + return; + } if (audioCapturerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!audioCapturerProcess || audioCapturerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Audio capturer process died during warmup')); } }, 50); }); - - audioCapturerStarting = null; })(); - return audioCapturerStarting; + audioCapturerStarting = startingPromise; + startingPromise.then( + () => { if (audioCapturerStarting === startingPromise) audioCapturerStarting = null; }, + () => { if (audioCapturerStarting === startingPromise) audioCapturerStarting = null; } + ); + return startingPromise; } async function startNativeAudioCapture(): Promise { @@ -3072,9 +3207,18 @@ function resolveAppIconDataUrl(appPath: string, size = 32): string | null { return null; } } +const appIconDataUrlCache = createAppIconDataUrlCache({ resolveAppIconDataUrl }); +const fileIconDataUrlCache = createFileIconDataUrlCache({ + getFileIcon: (filePath, options) => app.getFileIcon(filePath, options), +}); let launcherEntryFrontmostApp: FrontmostAppContext | null = null; const registeredHotkeys = new Map(); // shortcut → commandId -const activeAIRequests = new Map(); // requestId → controller +type ActiveAIRequest = { + controller: AbortController; + stream: AIStreamIpcCoalescer; +}; +const activeAIRequests = new Map(); // requestId → active request +const activeOllamaPullRequests = new Map(); // requestId → controller const pendingOAuthCallbackUrls: string[] = []; let snippetExpanderProcess: any = null; let snippetExpanderStdoutBuffer = ''; @@ -3096,7 +3240,26 @@ let emojiPickerWindow: InstanceType | null = null; let emojiPickerCurrentQuery = ''; let emojiPickerCurrentPrefixLen = 1; let emojiPickerSelectedIdx = 0; +let emojiPickerCurrentMatches: EmojiEntry[] = []; let nativeSpeechProcess: any = null; + +function startActiveAIRequest(requestId: string, sender: { send: (channel: 'ai-stream-chunk', payload: { requestId: string; chunk: string }) => void }): ActiveAIRequest { + activeAIRequests.get(requestId)?.stream.flush(); + const controller = new AbortController(); + const request = { + controller, + stream: createAIStreamIpcCoalescer({ requestId, sender }), + }; + activeAIRequests.set(requestId, request); + return request; +} + +function finishActiveAIRequest(requestId: string, request: ActiveAIRequest): void { + request.stream.flush(); + if (activeAIRequests.get(requestId) === request) { + activeAIRequests.delete(requestId); + } +} let nativeSpeechStdoutBuffer = ''; let nativeColorPickerPromise: Promise | null = null; let keyboardLockProcess: any = null; @@ -3197,48 +3360,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 { @@ -5715,6 +5842,43 @@ function resolveElevenLabsTtsConfig(selectedModel: string): { modelId: string; v return { modelId, voiceId }; } +type BufferedRequestPart = Buffer; + +function getBufferedRequestPartsContentLength(parts: readonly BufferedRequestPart[]): number { + return parts.reduce((total, part) => total + part.length, 0); +} + +function writeBufferedRequestParts(req: { write: (chunk: Buffer) => unknown; end: () => unknown }, parts: readonly BufferedRequestPart[]): void { + for (const part of parts) { + req.write(part); + } + req.end(); +} + +function collectBoundedResponseText(res: any, maxBytes = 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + const { StringDecoder } = require('string_decoder') as typeof import('string_decoder'); + const decoder = new StringDecoder('utf8'); + let remaining = maxBytes; + let text = ''; + + res.on('data', (chunk: Buffer | string) => { + if (remaining <= 0) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer; + remaining -= next.length; + text += decoder.write(next); + }); + res.on('error', reject); + res.on('end', () => { + if (remaining > 0) { + text += decoder.end(); + } + resolve(text); + }); + }); +} + function transcribeAudioWithElevenLabs(opts: { audioBuffer: Buffer; apiKey: string; @@ -5755,7 +5919,6 @@ function transcribeAudioWithElevenLabs(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5768,14 +5931,11 @@ function transcribeAudioWithElevenLabs(opts: { headers: { 'xi-api-key': opts.apiKey, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': getBufferedRequestPartsContentLength(parts), }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const responseBody = Buffer.concat(chunks).toString('utf-8'); + collectBoundedResponseText(res).then((responseBody) => { if (res.statusCode && res.statusCode >= 400) { if (res.statusCode === 401 && responseBody.includes('detected_unusual_activity')) { reject(new Error('ElevenLabs rejected this key due to account restrictions (detected_unusual_activity). Verify plan/account status in ElevenLabs dashboard.')); @@ -5800,12 +5960,11 @@ function transcribeAudioWithElevenLabs(opts: { } resolve(text); } - }); + }).catch(reject); } ); req.on('error', reject); - req.write(body); - req.end(); + writeBufferedRequestParts(req, parts); } catch (error) { reject(error); } @@ -5842,7 +6001,6 @@ function transcribeAudioWithMistralVoxtral(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5855,14 +6013,11 @@ function transcribeAudioWithMistralVoxtral(opts: { headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': getBufferedRequestPartsContentLength(parts), }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const responseBody = Buffer.concat(chunks).toString('utf-8'); + collectBoundedResponseText(res).then((responseBody) => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`Mistral Voxtral STT HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); return; @@ -5889,15 +6044,14 @@ function transcribeAudioWithMistralVoxtral(opts: { } resolve(text); } - }); + }).catch(reject); } ); req.on('error', reject); req.setTimeout(60000, () => { req.destroy(new Error('Mistral Voxtral STT timed out.')); }); - req.write(body); - req.end(); + writeBufferedRequestParts(req, parts); } catch (error) { reject(error); } @@ -5928,27 +6082,38 @@ function synthesizeElevenLabsToFile(opts: { }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 400) { - const responseText = Buffer.concat(chunks).toString('utf-8'); + if (res.statusCode && res.statusCode >= 400) { + collectBoundedResponseText(res).then((responseText) => { if (res.statusCode === 401 && responseText.includes('detected_unusual_activity')) { reject(new Error('ElevenLabs rejected this key due to account restrictions (detected_unusual_activity). Verify plan/account status in ElevenLabs dashboard.')); return; } reject(new Error(`ElevenLabs TTS HTTP ${res.statusCode}: ${responseText.slice(0, 500)}`)); return; + }).catch(reject); + return; + } + + const fileStream = fs.createWriteStream(opts.audioPath); + const { pipeline } = require('stream') as typeof import('stream'); + let audioBytes = 0; + + res.on('data', (chunk: Buffer) => { + audioBytes += chunk.length; + }); + + pipeline(res, fileStream, (err: Error | null) => { + if (err) { + try { fs.unlink(opts.audioPath, () => {}); } catch {} + reject(err); + return; } - const audio = Buffer.concat(chunks); - if (!audio.length) { + if (!audioBytes) { + try { fs.unlink(opts.audioPath, () => {}); } catch {} reject(new Error('ElevenLabs TTS returned empty audio.')); return; } - fs.writeFile(opts.audioPath, audio, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); + resolve(); }); } ); @@ -7369,8 +7534,19 @@ app.on('open-url', (event: any, url: string) => { // ─── Menu Bar (Tray) Management ───────────────────────────────────── const menuBarTrays = new Map>(); +const menuBarNativeImageCache = createMenuBarNativeImageCache(); +const menuBarNativeUpdateStates = new Map(); let appTray: InstanceType | null = null; +function getMenuBarNativeUpdateState(extId: string): MenuBarNativeUpdateState { + let state = menuBarNativeUpdateStates.get(extId); + if (!state) { + state = createMenuBarNativeUpdateState(); + menuBarNativeUpdateStates.set(extId, state); + } + return state; +} + function buildDefaultMacTrayTemplateIcon(): any | null { if (process.platform !== 'darwin') return null; try { @@ -10104,6 +10280,7 @@ async function renderEmojiPicker(query: string, caret: CaretRect | null, prefixL emojiPickerCurrentPrefixLen = prefixLen; emojiPickerSelectedIdx = 0; const matches = searchEmojiTriggerMatches(query); + emojiPickerCurrentMatches = matches; if (matches.length === 0) { hideEmojiPicker(); return; @@ -10126,7 +10303,7 @@ async function renderEmojiPicker(query: string, caret: CaretRect | null, prefixL function updateEmojiPickerSelection(delta: number): void { if (!emojiPickerWindow || emojiPickerWindow.isDestroyed() || !emojiPickerWindow.isVisible()) return; - const matches = searchEmojiTriggerMatches(emojiPickerCurrentQuery); + const matches = emojiPickerCurrentMatches; if (matches.length === 0) return; emojiPickerSelectedIdx = (emojiPickerSelectedIdx + delta + matches.length) % matches.length; const js = `window.__render(${JSON.stringify(matches)}, ${emojiPickerSelectedIdx});`; @@ -10137,6 +10314,7 @@ function hideEmojiPicker(): void { emojiPickerCurrentQuery = ''; emojiPickerCurrentPrefixLen = 1; // reset so a stale value never corrupts deletion count emojiPickerSelectedIdx = 0; + emojiPickerCurrentMatches = []; writeEmojiTriggerCmd({ cmd: 'intercept', enabled: false }); if (emojiPickerWindow && !emojiPickerWindow.isDestroyed() && emojiPickerWindow.isVisible()) { try { emojiPickerWindow.hide(); } catch {} @@ -10181,7 +10359,7 @@ async function insertEmojiReplacingTrigger(emoji: string, queryLen: number, pref } function handleEmojiTriggerNav(key: string): void { - const matches = searchEmojiTriggerMatches(emojiPickerCurrentQuery); + const matches = emojiPickerCurrentMatches; if (matches.length === 0) { hideEmojiPicker(); return; } if (key === 'left') { updateEmojiPickerSelection(-1); @@ -10244,6 +10422,7 @@ function startEmojiTriggerMonitor(triggerPrefix = ':'): void { '-O', '-o', binaryPath, path.join(nativeDir, 'emoji-trigger-monitor.swift'), path.join(nativeDir, 'ax-caret-query.swift'), + path.join(nativeDir, 'emoji-caret-session-cache.swift'), '-framework', 'AppKit', '-framework', 'ApplicationServices', ]); @@ -13315,19 +13494,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(() => { @@ -13656,30 +13839,9 @@ app.whenReady().then(async () => { const url = new URL(request.url); // canvas-lib: serve files from the canvas-lib directory if (url.hostname === 'canvas-lib') { - let relPath = decodeURIComponent(url.pathname || '').replace(/^\//, ''); - if (!relPath) return new Response('Bad Request', { status: 400 }); - const canvasLibPath = path.join(app.getPath('userData'), 'canvas-lib'); - const fullPath = path.join(canvasLibPath, relPath); - console.log('[sc-asset:canvas-lib] Serving:', fullPath); - try { - const data = fs.readFileSync(fullPath); - const ext = path.extname(fullPath).toLowerCase(); - const mimeTypes: Record = { - '.js': 'application/javascript', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', - '.png': 'image/png', - }; - return new Response(data, { - headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }, - }); - } catch (e) { - console.error('[sc-asset:canvas-lib] File not found:', fullPath); - return new Response('Not Found', { status: 404 }); - } + return serveCanvasLibAssetFromFile(request.url, getCanvasLibDir(), (fileUrl) => + net.fetch(fileUrl) + ); } if (url.hostname !== 'ext-asset') { @@ -15142,7 +15304,7 @@ app.whenReady().then(async () => { delete metadata[executed.commandId]; } saveSettings({ commandMetadata: metadata }); - invalidateCache(); + applyCommandMetadataUpdate(executed.commandId, { subtitle: subtitle || null }); } if (!background && (executed.mode === 'compact' || executed.mode === 'silent')) { @@ -15237,7 +15399,10 @@ app.whenReady().then(async () => { saveSettings({ commandMetadata: settings.commandMetadata }); // Notify all windows to refresh command list - invalidateCache(); + const patchResult = applyCommandMetadataUpdate(commandId, metadata); + if (patchResult.changedCommands > 0) { + broadcastCommandsUpdated(); + } return { success: true }; } catch (e: any) { console.error('update-command-metadata error:', e); @@ -15249,6 +15414,15 @@ app.whenReady().then(async () => { // ─── IPC: Extension APIs (for @raycast/api compatibility) ──────── // HTTP request proxy (so extensions can make Node.js HTTP requests without CORS) + const activeHttpRequests = new Map void>(); + + ipcMain.on('http-request-cancel', (_event: any, requestId: string) => { + if (!requestId) return; + const cancel = activeHttpRequests.get(requestId); + if (!cancel) return; + cancel(); + }); + ipcMain.handle( 'http-request', async ( @@ -15258,6 +15432,7 @@ app.whenReady().then(async () => { method?: string; headers?: Record; body?: string; + requestId?: string; } ) => { const http = require('http'); @@ -15275,17 +15450,39 @@ app.whenReady().then(async () => { } } catch {} + const requestId = typeof options.requestId === 'string' ? options.requestId : ''; + let canceled = false; + const resolveCanceled = (url: string) => ({ + status: 0, + statusText: 'Request canceled', + headers: {}, + bodyText: '', + url, + }); + const doRequest = (url: string, method: string, headers: Record, body: string | undefined, redirectsLeft: number): Promise => { return new Promise((resolve) => { + let settled = false; + const settle = (value: any) => { + if (settled) return; + settled = true; + resolve(value); + }; + const settleCanceled = () => settle(resolveCanceled(url)); + + if (canceled) { + settleCanceled(); + return; + } + try { const parsedUrl = new URL(url); const transport = parsedUrl.protocol === 'https:' ? https : http; - const reqOptions: any = { hostname: parsedUrl.hostname, port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, - method: method, + method, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ...headers, @@ -15293,87 +15490,113 @@ app.whenReady().then(async () => { }; const req = transport.request(reqOptions, (res: any) => { - // Follow redirects (301, 302, 303, 307, 308) + if (canceled) { + res.resume(); + settleCanceled(); + return; + } + if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); // drain the response + res.resume(); const redirectUrl = new URL(res.headers.location, url).toString(); - const redirectMethod = (res.statusCode === 303) ? 'GET' : method; - const redirectBody = (res.statusCode === 303) ? undefined : body; - resolve(doRequest(redirectUrl, redirectMethod, headers, redirectBody, redirectsLeft - 1)); + const redirectMethod = res.statusCode === 303 ? 'GET' : method; + const redirectBody = res.statusCode === 303 ? undefined : body; + settle(doRequest(redirectUrl, redirectMethod, headers, redirectBody, redirectsLeft - 1)); return; } const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { + res.on('data', (chunk: Buffer) => { + if (!canceled) chunks.push(chunk); + }); + res.on('end', async () => { + if (canceled) { + settleCanceled(); + return; + } + const bodyBuffer = Buffer.concat(chunks); const contentEncoding = String(res.headers['content-encoding'] || '').toLowerCase(); - let decodedBuffer = bodyBuffer; - try { - const zlib = require('zlib'); - if (contentEncoding.includes('br')) { - decodedBuffer = zlib.brotliDecompressSync(bodyBuffer); - } else if (contentEncoding.includes('gzip')) { - decodedBuffer = zlib.gunzipSync(bodyBuffer); - } else if (contentEncoding.includes('deflate')) { - decodedBuffer = zlib.inflateSync(bodyBuffer); - } - } catch { - // If decompression fails, keep raw buffer to avoid hard-failing requests. - decodedBuffer = bodyBuffer; + const decodedBuffer = await decodeHttpResponseBodyBuffer(bodyBuffer, contentEncoding); + if (canceled) { + settleCanceled(); + return; } const responseHeaders: Record = {}; for (const [key, val] of Object.entries(res.headers)) { responseHeaders[key] = Array.isArray(val) ? val.join(', ') : String(val); } - resolve({ + settle({ status: res.statusCode, statusText: res.statusMessage || '', headers: responseHeaders, bodyText: decodedBuffer.toString('utf-8'), - url: url, + url, }); }); }); req.on('error', (err: Error) => { - resolve({ + if (canceled) { + settleCanceled(); + return; + } + + settle({ status: 0, statusText: err.message, headers: {}, bodyText: '', - url: url, + url, }); }); req.setTimeout(30000, () => { + if (settled) return; req.destroy(); - resolve({ + settle({ status: 0, statusText: 'Request timed out', headers: {}, bodyText: '', - url: url, + url, }); }); + if (requestId) { + activeHttpRequests.set(requestId, () => { + canceled = true; + if (settled) return; + try { + req.destroy(new Error('Request canceled')); + } catch { + req.destroy(); + } + settleCanceled(); + }); + } + if (body) { req.write(body); } req.end(); } catch (e: any) { - resolve({ + settle({ status: 0, statusText: e?.message || 'Request failed', headers: {}, bodyText: '', - url: url, + url, }); } }); }; - return doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + try { + return await doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + } finally { + if (requestId) activeHttpRequests.delete(requestId); + } } ); @@ -15384,91 +15607,9 @@ app.whenReady().then(async () => { _event: any, command: string, args: string[], - options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } + options?: ExecCommandOptions ) => { - const { spawn, execFile } = require('child_process'); - const fs = require('fs'); - - return new Promise((resolve) => { - try { - const resolveExecutablePath = (input: string): string => { - if (!input || typeof input !== 'string') return input; - if (!input.includes('/') && !input.includes('\\')) return input; - if (!input.startsWith('/')) return input; - if (fs.existsSync(input)) return input; - try { - const base = input.split('/').filter(Boolean).pop() || ''; - if (!base) return input; - const lookup = execFileSync('/bin/zsh', ['-lc', `command -v -- ${JSON.stringify(base)} 2>/dev/null || true`], { encoding: 'utf-8' }).trim(); - if (lookup && fs.existsSync(lookup)) return lookup; - } catch {} - return input; - }; - - const execFileSync = require('child_process').execFileSync; - const normalizedCommand = resolveExecutablePath(command); - // Augment PATH so extensions can find brew, npm, nvm, etc. even when - // the app is launched from the Dock (where macOS strips the login PATH). - const extraPaths = [ - '/opt/homebrew/bin', '/opt/homebrew/sbin', - '/usr/local/bin', '/usr/local/sbin', - '/usr/bin', '/usr/sbin', '/bin', '/sbin', - ]; - const currentPath = (options?.env?.PATH ?? process.env.PATH ?? ''); - const augmentedPath = [ - ...extraPaths, - ...currentPath.split(':').filter(Boolean), - ].filter((v, i, a) => a.indexOf(v) === i).join(':'); - const spawnOptions: any = { - shell: options?.shell ?? false, - env: { ...process.env, ...options?.env, PATH: augmentedPath }, - cwd: options?.cwd || process.cwd(), - }; - - let proc: any; - if (options?.shell) { - // When shell is true, join command and args - const fullCommand = [normalizedCommand, ...args].join(' '); - proc = spawn(fullCommand, [], { ...spawnOptions, shell: true }); - } else { - proc = spawn(normalizedCommand, args, spawnOptions); - } - - let stdout = ''; - let stderr = ''; - - proc.stdout?.on('data', (data: Buffer) => { - stdout += data.toString(); - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); - }); - - if (options?.input && proc.stdin) { - proc.stdin.write(options.input); - proc.stdin.end(); - } - - proc.on('close', (code: number | null) => { - resolve({ stdout, stderr, exitCode: code ?? 0 }); - }); - - proc.on('error', (err: Error) => { - resolve({ stdout, stderr: err.message, exitCode: 1 }); - }); - - // Timeout after 5 minutes — allows long-running commands (brew install, npm install, etc.) - setTimeout(() => { - try { - proc.kill(); - } catch {} - resolve({ stdout, stderr: stderr || 'Command timed out', exitCode: 124 }); - }, 300000); - } catch (e: any) { - resolve({ stdout: '', stderr: e?.message || 'Failed to execute command', exitCode: 1 }); - } - }); + return runExecCommand(command, args, options); } ); @@ -15476,7 +15617,58 @@ app.whenReady().then(async () => { // This is the generic fix for any extension that uses child_process.spawn with progressive output // (e.g. speedtest CLI outputting JSON lines, ffmpeg progress, etc.) { - const spawnedProcesses = new Map(); + const spawnedProcesses = new Map(); + const spawnedProcessPidsBySender = new WeakMap>(); + const senderCleanupRegistered = new WeakSet(); + + const forgetSpawnedProcess = (pid: number) => { + const entry = spawnedProcesses.get(pid); + if (!entry) return; + spawnedProcesses.delete(pid); + const senderPids = spawnedProcessPidsBySender.get(entry.sender); + senderPids?.delete(pid); + }; + + const terminateSpawnedProcess = (pid: number, signal?: string | number) => { + const entry = spawnedProcesses.get(pid); + if (!entry) return; + const proc = entry.proc; + const killSignal = signal ?? 'SIGTERM'; + forgetSpawnedProcess(pid); + try { + if (process.platform !== 'win32' && typeof proc.pid === 'number' && proc.pid > 0) { + process.kill(-proc.pid, killSignal as NodeJS.Signals | number); + } else { + proc.kill(killSignal); + } + } catch { + try { proc.kill(killSignal); } catch {} + } + }; + + const cleanupSenderSpawnedProcesses = (sender: any) => { + const senderPids = spawnedProcessPidsBySender.get(sender); + if (!senderPids) return; + for (const pid of Array.from(senderPids)) { + terminateSpawnedProcess(pid, 'SIGTERM'); + } + spawnedProcessPidsBySender.delete(sender); + }; + + const trackSenderSpawnedProcess = (sender: any, pid: number) => { + if (pid === -1 || !sender) return; + let senderPids = spawnedProcessPidsBySender.get(sender); + if (!senderPids) { + senderPids = new Set(); + spawnedProcessPidsBySender.set(sender, senderPids); + } + senderPids.add(pid); + if (!senderCleanupRegistered.has(sender)) { + senderCleanupRegistered.add(sender); + try { sender.once?.('destroyed', () => cleanupSenderSpawnedProcesses(sender)); } catch {} + try { sender.on?.('render-process-gone', () => cleanupSenderSpawnedProcesses(sender)); } catch {} + } + }; ipcMain.handle( 'spawn-process', @@ -15515,9 +15707,11 @@ app.whenReady().then(async () => { : spawn(resolvedFile, args || [], spawnOpts); const pid: number = proc.pid ?? -1; - if (pid !== -1) spawnedProcesses.set(pid, proc); - const sender = event.sender; + if (pid !== -1) { + spawnedProcesses.set(pid, { proc, sender }); + trackSenderSpawnedProcess(sender, pid); + } const safeSend = (channel: string, ...sendArgs: any[]) => { try { if (!sender.isDestroyed()) sender.send(channel, ...sendArgs); } catch {} }; @@ -15549,7 +15743,7 @@ app.whenReady().then(async () => { }); proc.on('close', (code: number | null) => { if (!finalize()) return; - spawnedProcesses.delete(pid); + forgetSpawnedProcess(pid); const exitCode = code ?? 0; const seq = nextSeq(); safeSendSpawnEvent({ pid, seq, type: 'exit', code: exitCode }); @@ -15558,7 +15752,7 @@ app.whenReady().then(async () => { }); proc.on('error', (err: Error) => { if (!finalize()) return; - spawnedProcesses.delete(pid); + forgetSpawnedProcess(pid); const message = err.message; const seq = nextSeq(); safeSendSpawnEvent({ pid, seq, type: 'error', message }); @@ -15571,7 +15765,7 @@ app.whenReady().then(async () => { ); ipcMain.on('spawn-stdin', (_event: any, pid: number, data: Uint8Array | string, end?: boolean) => { - const proc = spawnedProcesses.get(pid); + const proc = spawnedProcesses.get(pid)?.proc; if (!proc?.stdin) return; try { if (data != null && (typeof data === 'string' ? data.length > 0 : data.byteLength > 0)) { @@ -15582,20 +15776,7 @@ app.whenReady().then(async () => { }); ipcMain.handle('spawn-kill', (_event: any, pid: number, signal?: string | number) => { - const proc = spawnedProcesses.get(pid); - if (proc) { - const killSignal = signal ?? 'SIGTERM'; - try { - if (process.platform !== 'win32' && typeof proc.pid === 'number' && proc.pid > 0) { - process.kill(-proc.pid, killSignal as NodeJS.Signals | number); - } else { - proc.kill(killSignal); - } - } catch { - try { proc.kill(killSignal); } catch {} - } - spawnedProcesses.delete(pid); - } + terminateSpawnedProcess(pid, signal); }); } @@ -15670,17 +15851,53 @@ app.whenReady().then(async () => { // Download a URL to a binary buffer via Node.js (bypasses CORS — renderer fetch cannot // download from CDNs that don't send CORS headers, but Node.js has no such restriction). // Returns a Uint8Array which IPC transmits via structured clone without encoding overhead. - ipcMain.handle('http-download-binary', async (_event: any, url: string) => { + ipcMain.handle('http-download-binary', async (_event: any, url: string, options?: { requestId?: string }) => { const https = require('https'); const http = require('http'); const { execFile } = require('child_process'); const REQUEST_TIMEOUT_MS = 30_000; + const requestId = typeof options?.requestId === 'string' ? options.requestId : ''; + let canceled = false; + let activeCancel: (() => void) | undefined; + const createCanceledError = () => { + const err = new Error('Request canceled'); + err.name = 'AbortError'; + return err; + }; + const setActiveCancel = (cancel: () => void) => { + activeCancel = cancel; + if (!requestId) return; + activeHttpRequests.set(requestId, cancel); + }; + const clearActiveCancel = (cancel: () => void) => { + if (activeCancel === cancel) activeCancel = undefined; + if (requestId && activeHttpRequests.get(requestId) === cancel) { + activeHttpRequests.delete(requestId); + } + }; const downloadUrl = async (targetUrl: string, redirectCount = 0): Promise => { if (redirectCount > 10) throw new Error('Too many redirects'); + if (canceled) throw createCanceledError(); const parsed = new URL(targetUrl); return new Promise((resolve, reject) => { + let settled = false; + let chunks: Buffer[] = []; + const cleanupChunks = () => { + chunks = []; + }; + let cancelRequest: () => void = () => { + canceled = true; + cleanupChunks(); + }; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + cleanupChunks(); + clearActiveCancel(cancelRequest); + callback(); + }; const client = parsed.protocol === 'https:' ? https : http; const req = client.get( parsed.toString(), @@ -15691,28 +15908,58 @@ app.whenReady().then(async () => { }, }, (res: any) => { + if (canceled) { + res.resume(); + settle(() => reject(createCanceledError())); + return; + } if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); const redirectUrl = new URL(res.headers.location, parsed).toString(); - downloadUrl(redirectUrl, redirectCount + 1).then(resolve, reject); + settle(() => downloadUrl(redirectUrl, redirectCount + 1).then(resolve, reject)); return; } if (res.statusCode !== 200) { res.resume(); - reject(new Error(`HTTP ${res.statusCode}`)); + settle(() => reject(new Error(`HTTP ${res.statusCode}`))); return; } - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => resolve(new Uint8Array(Buffer.concat(chunks)))); - res.on('error', reject); + res.on('data', (chunk: Buffer) => { + if (!canceled) chunks.push(chunk); + }); + res.on('end', () => { + if (canceled) { + settle(() => reject(createCanceledError())); + return; + } + const bodyBuffer = Buffer.concat(chunks); + cleanupChunks(); + settle(() => resolve(new Uint8Array(bodyBuffer))); + }); + res.on('error', (err: Error) => { + settle(() => reject(canceled ? createCanceledError() : err)); + }); } ); req.setTimeout(REQUEST_TIMEOUT_MS, () => { req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)); }); - req.on('error', reject); + req.on('error', (err: Error) => { + settle(() => reject(canceled ? createCanceledError() : err)); + }); + cancelRequest = () => { + canceled = true; + cleanupChunks(); + if (settled) return; + try { + req.destroy(createCanceledError()); + } catch { + req.destroy(); + } + settle(() => reject(createCanceledError())); + }; + setActiveCancel(cancelRequest); }); }; @@ -15720,8 +15967,17 @@ app.whenReady().then(async () => { try { return await downloadUrl(url); } catch (primaryErr: any) { + if (canceled || primaryErr?.name === 'AbortError') throw createCanceledError(); const curlOutput = await new Promise((resolve, reject) => { - execFile( + let child: any; + const cancelCurl = () => { + canceled = true; + try { + child?.kill('SIGTERM'); + } catch {} + reject(createCanceledError()); + }; + child = execFile( '/usr/bin/curl', [ '-fsSL', @@ -15733,8 +15989,17 @@ app.whenReady().then(async () => { ], { encoding: null, maxBuffer: 100 * 1024 * 1024 }, (err: Error | null, stdout: Buffer, stderr: Buffer | string) => { + clearActiveCancel(cancelCurl); + if (canceled) { + reject(createCanceledError()); + return; + } if (err) { - const stderrText = typeof stderr === 'string' ? stderr : String(stderr || ''); + const stderrText = typeof stderr === 'string' + ? stderr.slice(0, 4096) + : Buffer.isBuffer(stderr) + ? stderr.toString('utf-8', 0, Math.min(stderr.length, 4096)) + : String(stderr || '').slice(0, 4096); reject( new Error( `HTTP download failed (${primaryErr?.message || 'unknown'}) and curl fallback failed (${stderrText || err.message})` @@ -15745,12 +16010,17 @@ app.whenReady().then(async () => { resolve(new Uint8Array(stdout)); } ); + setActiveCancel(cancelCurl); }); return curlOutput; } }; - return downloadWithCurlFallback(); + try { + return await downloadWithCurlFallback(); + } finally { + if (requestId) activeHttpRequests.delete(requestId); + } }); // Write raw binary data to a real file path (extensions use this for CLI tool downloads) @@ -15988,7 +16258,7 @@ return appURL's |path|() as text`, // actually targeted (bundlePath), so it does not depend on // lastFrontmostApp.path being populated. const iconPath = String(result?.appPath || targetAppPath || '').trim(); - const appIconDataUrl = iconPath ? resolveAppIconDataUrl(iconPath, 32) : null; + const appIconDataUrl = iconPath ? appIconDataUrlCache.resolveSync(iconPath, 32) : null; resolve({ ...result, appIconDataUrl }); } catch { resolve({ ok: false, error: stderr || 'Failed to parse menu item search output' }); @@ -16331,20 +16601,12 @@ return appURL's |path|() as text`, }); ipcMain.handle('get-file-icon-data-url', async (_event: any, filePath: string, size = 20) => { - try { - const icon = await app.getFileIcon(filePath, { size: size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal' }); - if (icon && !icon.isEmpty()) { - return icon.resize({ width: size, height: size }).toDataURL(); - } - return null; - } catch { - return null; - } + return fileIconDataUrlCache.resolve(filePath, size); }); // Get .app bundle icon by reading its .icns file directly (avoids template-image transparency issues) ipcMain.handle('get-app-icon-data-url', async (_event: any, appPath: string, size = 32) => { - return resolveAppIconDataUrl(appPath, size); + return appIconDataUrlCache.resolve(appPath, size); }); ipcMain.handle('file-search-query', async (_event: any, query: string, options?: { limit?: number }) => { @@ -16429,12 +16691,10 @@ return appURL's |path|() as text`, if (!success) { throw new Error(`Failed to install extension "${name}". Check SuperCmd main-process logs for details.`); } - // Invalidate the command cache and rebuild it BEFORE we broadcast, so + // Refresh extension commands BEFORE we broadcast, so // the renderer's follow-up get-commands fetch lands on fresh data - // rather than the stale fallback that getAvailableCommands() returns - // immediately after an invalidation. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after install failed:', e); } + // without rediscovering unrelated apps/settings when a cache exists. + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after install failed:', e); } broadcastExtensionsUpdated(); // The launcher's root list listens for 'commands-updated', not // 'extensions-updated' — without this, the new extension wouldn't @@ -16452,10 +16712,9 @@ return appURL's |path|() as text`, async (_event: any, name: string) => { const success = await uninstallExtension(name); if (success) { - // Invalidate the command cache and rebuild synchronously before + // Refresh extension commands synchronously before // broadcasting — see install-extension handler for context. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after uninstall failed:', e); } + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after uninstall failed:', e); } // Tell the launcher renderer to tear down any live runners (menu-bar // tray, background no-view loop, interval re-runner) for this // extension before its bundle keeps trying to re-mount itself. @@ -16531,12 +16790,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) => { @@ -17345,8 +17604,8 @@ if let tiff = image?.tiffRepresentation { return togglePinCanvas(id); }); - ipcMain.handle('canvas-get-scene', (_event: any, id: string) => { - return getScene(id); + ipcMain.handle('canvas-get-scene', async (_event: any, id: string) => { + return getSceneAsync(id); }); ipcMain.handle('canvas-save-scene', async (_event: any, id: string, scene: any) => { @@ -17368,8 +17627,8 @@ if let tiff = image?.tiffRepresentation { mainWindow?.webContents.send('canvas-thumbnail-updated', id); }); - ipcMain.handle('canvas-get-thumbnail', (_event: any, id: string) => { - return getThumbnail(id); + ipcMain.handle('canvas-get-thumbnail', async (_event: any, id: string) => { + return getThumbnailAsync(id); }); ipcMain.handle('open-canvas-window', (_event: any, mode?: string, canvasJson?: string) => { @@ -17644,8 +17903,8 @@ if let tiff = image?.tiffRepresentation { return; } - const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + const activeRequest = startActiveAIRequest(requestId, event.sender); + const { controller, stream } = activeRequest; try { const memoryContextSystemPrompt = await buildMemoryContextSystemPrompt( @@ -17665,30 +17924,35 @@ if let tiff = image?.tiffRepresentation { signal: controller.signal, }); - for await (const chunk of gen) { - if (controller.signal.aborted) break; - event.sender.send('ai-stream-chunk', { requestId, chunk }); - } + await forwardAIStreamChunksToIpc(gen, stream, controller.signal); if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-done', { requestId }); } } catch (e: any) { if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-error', { requestId, error: e?.message || 'AI request failed' }); } } finally { - activeAIRequests.delete(requestId); + finishActiveAIRequest(requestId, activeRequest); } } ); ipcMain.handle('ai-cancel', (_event: any, requestId: string) => { - const controller = activeAIRequests.get(requestId); - if (controller) { - controller.abort(); + const activeRequest = activeAIRequests.get(requestId); + if (activeRequest) { + activeRequest.stream.flush(); + activeRequest.controller.abort(); activeAIRequests.delete(requestId); } + const ollamaPullController = activeOllamaPullRequests.get(requestId); + if (ollamaPullController) { + ollamaPullController.abort(); + activeOllamaPullRequests.delete(requestId); + } }); ipcMain.handle( @@ -17709,8 +17973,8 @@ if let tiff = image?.tiffRepresentation { return; } - const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + const activeRequest = startActiveAIRequest(requestId, event.sender); + const { controller, stream } = activeRequest; try { const latestUser = [...(messages || [])].reverse().find((m) => m.role === 'user'); @@ -17731,20 +17995,19 @@ if let tiff = image?.tiffRepresentation { signal: controller.signal, }); - for await (const chunk of gen) { - if (controller.signal.aborted) break; - event.sender.send('ai-stream-chunk', { requestId, chunk }); - } + await forwardAIStreamChunksToIpc(gen, stream, controller.signal); if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-done', { requestId }); } } catch (e: any) { if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-error', { requestId, error: e?.message || 'AI request failed' }); } } finally { - activeAIRequests.delete(requestId); + finishActiveAIRequest(requestId, activeRequest); } } ); @@ -17778,11 +18041,11 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle('parakeet-download-model', async () => { await ensureParakeetModelDownloaded(); - return getParakeetModelStatus(); + return getParakeetModelStatus({ forceRefresh: true }); }); ipcMain.handle('parakeet-warmup', async () => { - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state !== 'downloaded') { return { ready: false, error: 'Models not downloaded' }; } @@ -17803,11 +18066,11 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle('qwen3-download-model', async () => { await ensureQwen3ModelDownloaded(); - return getQwen3ModelStatus(); + return getQwen3ModelStatus({ forceRefresh: true }); }); ipcMain.handle('qwen3-warmup', async () => { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state !== 'downloaded') { return { ready: false, error: 'Models not downloaded' }; } @@ -17892,102 +18155,28 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle( 'whisper-transcribe-file', async (_event: any, audioPath: string, options?: { language?: string }) => { - const s = loadSettings(); - if (isAIDisabledInSettings(s)) { - throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); - } - if (s.ai?.whisperEnabled === false) { - throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); - } - - if (!fs.existsSync(audioPath)) { - throw new Error(`Audio file not found: ${audioPath}`); - } - - const audioBuffer = fs.readFileSync(audioPath); - - // Reuse the existing whisper-transcribe logic by reading the file into a buffer - const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; - const language = normalizeWhisperLanguageCode(rawLang); - - let provider: 'parakeet' | 'qwen3' | 'whispercpp' | 'openai' | 'elevenlabs' | 'mistral' = 'whispercpp'; - let model = `ggml-${WHISPERCPP_MODEL_NAME}`; - const sttModel = s.ai.speechToTextModel || ''; - if (sttModel === 'parakeet') { - provider = 'parakeet'; - model = 'parakeet-tdt-0.6b-v3'; - } else if (sttModel === 'qwen3') { - provider = 'qwen3'; - model = 'qwen3-asr-0.6b'; - } else if (!sttModel || sttModel === 'default' || sttModel === 'whispercpp') { - provider = 'whispercpp'; - model = `ggml-${WHISPERCPP_MODEL_NAME}`; - } else if (sttModel === 'native') { - return ''; - } else if (sttModel.startsWith('openai-')) { - provider = 'openai'; - model = sttModel.slice('openai-'.length); - } else if (sttModel.startsWith('elevenlabs-')) { - provider = 'elevenlabs'; - model = resolveElevenLabsSttModel(sttModel); - } else if (sttModel.startsWith('mistral-')) { - provider = 'mistral'; - model = sttModel.slice('mistral-'.length) || 'voxtral-mini-latest'; - } else if (sttModel) { - model = sttModel; - } - - if (provider === 'openai' && !s.ai.openaiApiKey) { - throw new Error('OpenAI API key not configured.'); - } - const elevenLabsApiKey = getElevenLabsApiKey(s); - if (provider === 'elevenlabs' && !elevenLabsApiKey) { - throw new Error('ElevenLabs API key not configured.'); - } - const mistralApiKey = getMistralApiKey(s); - if (provider === 'mistral' && !mistralApiKey) { - throw new Error('Mistral API key not configured.'); - } - - // For whisper.cpp, use the file-path directly via the persistent server - // to avoid reading the file into a Node buffer just to write it again. - if (provider === 'whispercpp') { - const status = getWhisperCppModelStatus(); - if (status.state === 'downloading') { - throw new Error('Whisper model still downloading.'); - } - if (status.state !== 'downloaded') { - throw new Error('Whisper model not downloaded.'); - } - await ensureWhisperCppServer(); - const result = await sendWhisperCppRequest({ - command: 'transcribe', - file: audioPath, - language, - }); - // Clean up the temp file after transcription - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - return result.text || ''; - } - - // For cloud providers, use buffer-based transcription - const mimeType = 'audio/wav'; - const text = provider === 'parakeet' - ? await transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) - : provider === 'qwen3' - ? await transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) - : provider === 'elevenlabs' - ? await transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) - : provider === 'mistral' - ? await transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) - : await transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); - - // Clean up the temp file - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - - return text; + return await transcribeWhisperAudioFile({ + audioPath, + options, + deps: { + fs, + loadSettings, + isAIDisabledInSettings, + normalizeWhisperLanguageCode, + resolveElevenLabsSttModel, + getElevenLabsApiKey, + getMistralApiKey, + getWhisperCppModelStatus, + ensureWhisperCppServer, + sendWhisperCppRequest, + transcribeAudioWithParakeet, + transcribeAudioWithQwen3, + transcribeAudioWithElevenLabs, + transcribeAudioWithMistralVoxtral, + transcribeAudio, + whisperCppModelName: WHISPERCPP_MODEL_NAME, + }, + }); } ); ipcMain.handle( @@ -18270,7 +18459,7 @@ if let tiff = image?.tiffRepresentation { const mod = url.protocol === 'https:' ? require('https') : require('http'); const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + activeOllamaPullRequests.set(requestId, controller); const body = JSON.stringify({ name: modelName, stream: true }); @@ -18291,7 +18480,7 @@ if let tiff = image?.tiffRepresentation { requestId, error: `HTTP ${res.statusCode}: ${errBody.slice(0, 200)}`, }); - activeAIRequests.delete(requestId); + finishPullRequest(); }); return; } @@ -18335,7 +18524,7 @@ if let tiff = image?.tiffRepresentation { if (!controller.signal.aborted) { event.sender.send('ollama-pull-done', { requestId }); } - activeAIRequests.delete(requestId); + finishPullRequest(); }); } ); @@ -18347,16 +18536,34 @@ if let tiff = image?.tiffRepresentation { error: err.message || 'Failed to pull model', }); } - activeAIRequests.delete(requestId); + finishPullRequest(); }); + let abortListenerAttached = false; + let pullRequestFinished = false; + const onAbort = () => { + finishPullRequest(); + req.destroy(); + }; + const cleanupAbortListener = () => { + if (!abortListenerAttached) return; + controller.signal.removeEventListener('abort', onAbort); + abortListenerAttached = false; + }; + const finishPullRequest = () => { + if (pullRequestFinished) return; + pullRequestFinished = true; + cleanupAbortListener(); + activeOllamaPullRequests.delete(requestId); + }; + if (controller.signal.aborted) { + finishPullRequest(); req.destroy(); return; } - controller.signal.addEventListener('abort', () => { - req.destroy(); - }, { once: true }); + controller.signal.addEventListener('abort', onAbort, { once: true }); + abortListenerAttached = true; req.write(body); req.end(); @@ -19086,97 +19293,49 @@ if let tiff = image?.tiffRepresentation { // Update / create a menu-bar Tray when the renderer sends menu structure ipcMain.on('menubar-update', (_event: any, data: any) => { - const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, title, tooltip, items } = data; + const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, items } = data; let tray = menuBarTrays.get(extId); - - const createNativeImageFromMenuIcon = ( - payload: { pathValue?: string; dataUrlValue?: string; bitmapScale?: number }, - size: number, - ) => { - try { - const fs = require('fs'); - let image: any; - const dataUrlValue = String(payload?.dataUrlValue || '').trim(); - const pathValue = String(payload?.pathValue || '').trim(); - const requestedScale = Number(payload?.bitmapScale); - const bitmapScale = Number.isFinite(requestedScale) && requestedScale >= 1 ? requestedScale : 1; - - if (dataUrlValue.startsWith('data:')) { - // For raster PNG data URLs that the renderer pre-rasterized at higher - // DPR (bitmapScale > 1), reconstruct via createFromBuffer with the - // matching scaleFactor so the Tray treats it as a retina rep instead - // of stretching a low-res bitmap. - const isRasterPng = dataUrlValue.startsWith('data:image/png'); - if (isRasterPng && bitmapScale > 1) { - const commaIdx = dataUrlValue.indexOf(','); - const base64Body = commaIdx >= 0 ? dataUrlValue.slice(commaIdx + 1) : ''; - const buf = base64Body ? Buffer.from(base64Body, 'base64') : null; - if (buf && buf.length > 0) { - image = nativeImage.createFromBuffer(buf, { scaleFactor: bitmapScale }); - } - } - if (!image || image.isEmpty?.()) { - image = nativeImage.createFromDataURL(dataUrlValue); - } - } else { - if (!pathValue || !fs.existsSync(pathValue)) return null; - image = nativeImage.createFromPath(pathValue); - if ((!image || image.isEmpty()) && /\.svg$/i.test(pathValue)) { - const svg = fs.readFileSync(pathValue, 'utf8'); - const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; - image = nativeImage.createFromDataURL(svgDataUrl); - } - } - if (!image || image.isEmpty()) return null; - - // When the source already carries a retina backing (scaleFactor > 1), - // resizing to logical px would discard the @2x rep — keep it intact. - const currentSize = image.getSize?.() || { width: 0, height: 0 }; - if (bitmapScale > 1 && currentSize.width === size && currentSize.height === size) { - return image; - } - return image.resize({ width: size, height: size, quality: 'best' }); - } catch { - return null; - } - }; + const updateState = getMenuBarNativeUpdateState(extId); let lastResolvedTrayIconOk = false; const hasEmojiIcon = typeof iconEmoji === 'string' && iconEmoji.trim().length > 0; + const isPrimaryGeneratedDataUrl = typeof iconDataUrl === 'string' && iconDataUrl.startsWith('data:'); + const isPrimarySvgPath = /\.svg$/i.test(iconPath || ''); + const primaryTemplate = + typeof iconTemplate === 'boolean' + ? iconTemplate + : (isPrimaryGeneratedDataUrl ? true : !isPrimarySvgPath); const resolveTrayIcon = () => { - const primaryImg = createNativeImageFromMenuIcon( - { pathValue: iconPath, dataUrlValue: iconDataUrl, bitmapScale: iconBitmapScale }, - 18, - ); - const usingPrimary = Boolean(primaryImg); + const primaryImg = createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + pathValue: iconPath, + dataUrlValue: iconDataUrl, + bitmapScale: iconBitmapScale, + size: 18, + template: primaryTemplate, + resizeQuality: 'best', + }); // When the extension supplies an emoji as its tray icon (e.g. "🎉"), we render that // emoji as the tray title and leave the tray image empty. Falling back to the // extension's package icon here would produce two visuals side-by-side in one slot. const img = primaryImg || - (hasEmojiIcon ? null : createNativeImageFromMenuIcon({ dataUrlValue: fallbackIconDataUrl }, 18)); + (hasEmojiIcon + ? null + : createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + dataUrlValue: fallbackIconDataUrl, + size: 18, + template: false, + resizeQuality: 'best', + })); lastResolvedTrayIconOk = Boolean(img); - if (img) { - // Raycast icon tokens are serialized as data URLs and should be template images - // so macOS can adapt them to menu bar foreground contrast. - const isGeneratedDataUrl = typeof iconDataUrl === 'string' && iconDataUrl.startsWith('data:'); - // Keep template rendering for bitmap assets (classic menubar style). - // For SVG asset paths, preserve source appearance (e.g., explicit light/dark icon variants). - const isSvg = /\.svg$/i.test(iconPath || ''); - const shouldTemplate = - !usingPrimary - ? false - : ( - typeof iconTemplate === 'boolean' - ? iconTemplate - : (isGeneratedDataUrl ? true : !isSvg) - ); - try { - img.setTemplateImage(shouldTemplate); - } catch {} - return img; - } + if (img) return img; return nativeImage.createEmpty(); }; @@ -19184,33 +19343,39 @@ if let tiff = image?.tiffRepresentation { const icon = resolveTrayIcon(); tray = new Tray(icon); menuBarTrays.set(extId, tray); + rememberMenuBarNativeIcon(updateState, data, lastResolvedTrayIconOk); + } else if (isMenuBarNativeIconRefreshNeeded(updateState, data)) { + // Refresh icon on accepted updates after creation (first payload can be incomplete). + tray.setImage(resolveTrayIcon()); + rememberMenuBarNativeIcon(updateState, data, lastResolvedTrayIconOk); } - // Always refresh icon on update (first payload can be incomplete). - tray.setImage(resolveTrayIcon()); - // Update title: if there's a text title, show it; if only emoji icon, show that - if (title) { - tray.setTitle(title); - } else if (iconEmoji) { - tray.setTitle(iconEmoji); - } else if (!lastResolvedTrayIconOk) { - // Keep tray visible even when extension provides neither icon nor title. - tray.setTitle('⏱'); - } else { - tray.setTitle(''); + const nextTitle = getMenuBarNativeTitle(data, updateState.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(updateState, nextTitle)) { + tray.setTitle(nextTitle); + rememberMenuBarNativeTitle(updateState, nextTitle); + } + const nextTooltip = getMenuBarNativeTooltip(data); + if (isMenuBarNativeTooltipUpdateNeeded(updateState, nextTooltip)) { + tray.setToolTip(nextTooltip); + rememberMenuBarNativeTooltip(updateState, nextTooltip); } - if (tooltip) tray.setToolTip(tooltip); // Build native menu from serialized items - const menuTemplate = buildMenuBarTemplate(items, extId); - const menu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(menu); + const nextItemsKey = getMenuBarNativeItemsKey(items); + if (isMenuBarNativeMenuUpdateNeeded(updateState, nextItemsKey)) { + const menuTemplate = buildMenuBarTemplate(items, extId); + const menu = Menu.buildFromTemplate(menuTemplate); + tray.setContextMenu(menu); + rememberMenuBarNativeMenu(updateState, nextItemsKey); + } }); ipcMain.on('menubar-remove', (_event: any, data: any) => { const extId = String(data?.extId || '').trim(); if (!extId) return; + menuBarNativeUpdateStates.delete(extId); const tray = menuBarTrays.get(extId); if (!tray) return; try { @@ -19225,31 +19390,19 @@ if let tiff = image?.tiffRepresentation { const iconDataUrl = typeof item?.iconDataUrl === 'string' ? item.iconDataUrl.trim() : ''; const iconPath = typeof item?.iconPath === 'string' ? item.iconPath : ''; const explicitTemplate = typeof item?.iconTemplate === 'boolean' ? item.iconTemplate : undefined; - try { - let img: any; - if (iconDataUrl.startsWith('data:')) { - img = nativeImage.createFromDataURL(iconDataUrl); - } else { - if (!iconPath) return undefined; - const fs = require('fs'); - if (!fs.existsSync(iconPath)) return undefined; - img = nativeImage.createFromPath(iconPath); - if ((!img || img.isEmpty()) && /\.svg$/i.test(iconPath)) { - const svg = fs.readFileSync(iconPath, 'utf8'); - const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; - img = nativeImage.createFromDataURL(svgDataUrl); - } - } - if (!img || img.isEmpty()) return undefined; - const shouldTemplate = - explicitTemplate ?? (iconDataUrl.startsWith('data:image/svg+xml') ? true : false); - const resized = img.resize({ width: 16, height: 16 }); - try { - resized.setTemplateImage(shouldTemplate); - } catch {} - return resized; - } catch {} - return undefined; + const shouldTemplate = + explicitTemplate ?? (iconDataUrl.startsWith('data:image/svg+xml') ? true : false); + const image = createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + pathValue: iconPath, + dataUrlValue: iconDataUrl, + bitmapScale: item?.iconBitmapScale, + size: 16, + template: shouldTemplate, + }); + return image || undefined; }; const labelWithEmoji = (item: any) => { @@ -19424,8 +19577,42 @@ 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'; + const shouldFlushNotes = hasPendingNotesSave(); + const shouldFlushClipboard = !updateRestartInProgress && 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', () => { @@ -19451,7 +19638,7 @@ app.on('will-quit', () => { killParakeetServer(); killQwen3Server(); killAudioCapturer(); - stopClipboardMonitor(); + void stopClipboardMonitor(); stopSnippetExpander(); stopEmojiTriggerMonitor(); stopFileSearchIndexing(); @@ -19465,4 +19652,6 @@ app.on('will-quit', () => { tray.destroy(); } menuBarTrays.clear(); + menuBarNativeImageCache.clear(); + menuBarNativeUpdateStates.clear(); }); diff --git a/src/main/menubar-native-image-cache.ts b/src/main/menubar-native-image-cache.ts new file mode 100644 index 00000000..6a6abc77 --- /dev/null +++ b/src/main/menubar-native-image-cache.ts @@ -0,0 +1,273 @@ +/** + * Main-process MenuBarExtra native image cache. + * + * Electron's NativeImage creation can decode and resize bitmap data every time + * a menu-bar payload is accepted. This helper keeps successful decoded/resized + * images keyed by visible icon inputs while preserving template-image state. + */ + +export type MenuBarNativeImageLike = { + isEmpty?: () => boolean; + getSize?: () => { width: number; height: number }; + resize?: (options: { width: number; height: number; quality?: string }) => MenuBarNativeImageLike; + setTemplateImage?: (isTemplate: boolean) => void; +}; + +export type MenuBarNativeImageFactory = { + createFromBuffer: (buffer: Buffer, options?: { scaleFactor?: number }) => MenuBarNativeImageLike; + createFromDataURL: (dataUrl: string) => MenuBarNativeImageLike; + createFromPath: (pathValue: string) => MenuBarNativeImageLike; +}; + +export type MenuBarFsLike = { + statSync: (pathValue: string) => { + size?: number; + mtimeMs?: number; + isFile?: () => boolean; + }; + readFileSync: (pathValue: string, encoding: 'utf8') => string; +}; + +export type MenuBarNativeImageCache = { + getImage: (key: string) => MenuBarNativeImageLike | null; + setImage: (key: string, image: MenuBarNativeImageLike) => void; + getSvgDataUrl: (key: string) => string | null; + setSvgDataUrl: (key: string, dataUrl: string) => void; + clear: () => void; + readonly imageSize: number; + readonly svgDataUrlSize: number; +}; + +export type CreateCachedMenuBarNativeImageOptions = { + cache: MenuBarNativeImageCache; + nativeImage: MenuBarNativeImageFactory; + fs: MenuBarFsLike; + pathValue?: string; + dataUrlValue?: string; + bitmapScale?: number; + size: number; + template: boolean; + resizeQuality?: string; +}; + +const DEFAULT_MAX_ENTRIES = 256; + +class BoundedLruMap { + private readonly maxEntries: number; + private readonly entries = new Map(); + + constructor(maxEntries: number) { + this.maxEntries = Math.max(1, Math.floor(maxEntries)); + } + + get size(): number { + return this.entries.size; + } + + get(key: string): T | null { + if (!this.entries.has(key)) return null; + const value = this.entries.get(key) as T; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: T): void { + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } +} + +class MenuBarNativeImageCacheImpl implements MenuBarNativeImageCache { + private readonly images: BoundedLruMap; + private readonly svgDataUrls: BoundedLruMap; + + constructor(maxEntries: number) { + this.images = new BoundedLruMap(maxEntries); + this.svgDataUrls = new BoundedLruMap(maxEntries); + } + + get imageSize(): number { + return this.images.size; + } + + get svgDataUrlSize(): number { + return this.svgDataUrls.size; + } + + getImage(key: string): MenuBarNativeImageLike | null { + return this.images.get(key); + } + + setImage(key: string, image: MenuBarNativeImageLike): void { + this.images.set(key, image); + } + + getSvgDataUrl(key: string): string | null { + return this.svgDataUrls.get(key); + } + + setSvgDataUrl(key: string, dataUrl: string): void { + this.svgDataUrls.set(key, dataUrl); + } + + clear(): void { + this.images.clear(); + this.svgDataUrls.clear(); + } +} + +export function createMenuBarNativeImageCache(maxEntries = DEFAULT_MAX_ENTRIES): MenuBarNativeImageCache { + return new MenuBarNativeImageCacheImpl(maxEntries); +} + +export function normalizeMenuBarBitmapScale(bitmapScale: unknown): number { + const requestedScale = Number(bitmapScale); + return Number.isFinite(requestedScale) && requestedScale >= 1 ? requestedScale : 1; +} + +export function createCachedMenuBarNativeImage( + options: CreateCachedMenuBarNativeImageOptions, +): MenuBarNativeImageLike | null { + const { + cache, + nativeImage, + fs, + size, + template, + resizeQuality, + } = options; + const dataUrlValue = String(options.dataUrlValue || '').trim(); + const pathValue = String(options.pathValue || '').trim(); + const bitmapScale = normalizeMenuBarBitmapScale(options.bitmapScale); + const resizeKey = `size=${size}|scale=${bitmapScale}|template=${template ? 1 : 0}|quality=${resizeQuality || ''}`; + + try { + if (dataUrlValue.startsWith('data:')) { + const key = `data:${dataUrlValue}|${resizeKey}`; + const cached = cache.getImage(key); + if (cached) return cached; + + let image: MenuBarNativeImageLike | null = null; + const isRasterPng = dataUrlValue.startsWith('data:image/png'); + if (isRasterPng && bitmapScale > 1) { + const commaIdx = dataUrlValue.indexOf(','); + const base64Body = commaIdx >= 0 ? dataUrlValue.slice(commaIdx + 1) : ''; + const buffer = base64Body ? Buffer.from(base64Body, 'base64') : null; + if (buffer && buffer.length > 0) { + image = nativeImage.createFromBuffer(buffer, { scaleFactor: bitmapScale }); + } + } + if (isEmptyNativeImage(image)) { + image = nativeImage.createFromDataURL(dataUrlValue); + } + const prepared = prepareNativeImageForMenu(image, size, bitmapScale, template, resizeQuality); + if (!prepared) return null; + cache.setImage(key, prepared); + return prepared; + } + + if (!pathValue) return null; + const pathIdentity = getMenuBarPathIdentity(fs, pathValue); + if (!pathIdentity) return null; + + const key = `path:${pathIdentity.cacheKey}|${resizeKey}`; + const cached = cache.getImage(key); + if (cached) return cached; + + let image = nativeImage.createFromPath(pathValue); + if (isEmptyNativeImage(image) && pathIdentity.isSvg) { + const svgDataUrl = getCachedSvgDataUrl(cache, fs, pathValue, pathIdentity.cacheKey); + if (svgDataUrl) image = nativeImage.createFromDataURL(svgDataUrl); + } + const prepared = prepareNativeImageForMenu(image, size, bitmapScale, template, resizeQuality); + if (!prepared) return null; + cache.setImage(key, prepared); + return prepared; + } catch { + return null; + } +} + +function getMenuBarPathIdentity( + fs: MenuBarFsLike, + pathValue: string, +): { cacheKey: string; isSvg: boolean } | null { + try { + const stat = fs.statSync(pathValue); + if (typeof stat?.isFile === 'function' && !stat.isFile()) return null; + const size = Number.isFinite(Number(stat?.size)) ? Number(stat.size) : 0; + const mtimeMs = Number.isFinite(Number(stat?.mtimeMs)) ? Number(stat.mtimeMs) : 0; + return { + cacheKey: `${pathValue}|mtimeMs=${mtimeMs}|bytes=${size}`, + isSvg: /\.svg$/i.test(pathValue), + }; + } catch { + return null; + } +} + +function getCachedSvgDataUrl( + cache: MenuBarNativeImageCache, + fs: MenuBarFsLike, + pathValue: string, + pathIdentityKey: string, +): string | null { + const cached = cache.getSvgDataUrl(pathIdentityKey); + if (cached) return cached; + try { + const svg = fs.readFileSync(pathValue, 'utf8'); + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + cache.setSvgDataUrl(pathIdentityKey, dataUrl); + return dataUrl; + } catch { + return null; + } +} + +function prepareNativeImageForMenu( + image: MenuBarNativeImageLike | null | undefined, + size: number, + bitmapScale: number, + template: boolean, + resizeQuality?: string, +): MenuBarNativeImageLike | null { + if (isEmptyNativeImage(image)) return null; + let prepared = image as MenuBarNativeImageLike; + + const currentSize = prepared.getSize?.() || { width: 0, height: 0 }; + const shouldKeepRetinaRep = + bitmapScale > 1 && currentSize.width === size && currentSize.height === size; + if (!shouldKeepRetinaRep) { + if (typeof prepared.resize !== 'function') return null; + prepared = prepared.resize({ + width: size, + height: size, + ...(resizeQuality ? { quality: resizeQuality } : {}), + }); + if (isEmptyNativeImage(prepared)) return null; + } + + try { + prepared.setTemplateImage?.(template); + } catch {} + return prepared; +} + +function isEmptyNativeImage(image: MenuBarNativeImageLike | null | undefined): boolean { + if (!image) return true; + try { + return Boolean(image.isEmpty?.()); + } catch { + return true; + } +} diff --git a/src/main/menubar-native-update-cache.ts b/src/main/menubar-native-update-cache.ts new file mode 100644 index 00000000..e5db8293 --- /dev/null +++ b/src/main/menubar-native-update-cache.ts @@ -0,0 +1,144 @@ +/** + * Main-process MenuBarExtra native update cache. + * + * Tracks the last native tray sub-payload applied per extension so title-only + * ticks do not rebuild unchanged Electron menu templates. + */ + +export type MenuBarNativeUpdatePayload = { + iconPath?: unknown; + iconDataUrl?: unknown; + iconEmoji?: unknown; + iconTemplate?: unknown; + iconBitmapScale?: unknown; + fallbackIconDataUrl?: unknown; + title?: unknown; + tooltip?: unknown; + items?: unknown; +}; + +export type MenuBarNativeUpdateState = { + iconKey: string | null; + lastResolvedTrayIconOk: boolean; + title: string | null; + tooltip: string | null; + itemsKey: string | null; +}; + +export function createMenuBarNativeUpdateState(): MenuBarNativeUpdateState { + return { + iconKey: null, + lastResolvedTrayIconOk: false, + title: null, + tooltip: null, + itemsKey: null, + }; +} + +export function getMenuBarNativeIconKey(payload: MenuBarNativeUpdatePayload): string { + return safeJsonStringify([ + payload.iconPath ?? null, + payload.iconDataUrl ?? null, + payload.iconEmoji ?? null, + typeof payload.iconTemplate === 'boolean' ? payload.iconTemplate : null, + payload.iconBitmapScale ?? null, + payload.fallbackIconDataUrl ?? null, + ]); +} + +export function hasFileBackedMenuBarNativeIcon(payload: MenuBarNativeUpdatePayload): boolean { + return typeof payload.iconPath === 'string' && payload.iconPath.trim().length > 0; +} + +export function isMenuBarNativeIconRefreshNeeded( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, +): boolean { + const nextIconKey = getMenuBarNativeIconKey(payload); + return state.iconKey !== nextIconKey || hasFileBackedMenuBarNativeIcon(payload); +} + +export function rememberMenuBarNativeIcon( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, +): void { + state.iconKey = getMenuBarNativeIconKey(payload); + state.lastResolvedTrayIconOk = lastResolvedTrayIconOk; +} + +export function getMenuBarNativeTitle( + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, +): string { + const title = normalizeMenuBarNativeString(payload.title); + if (title) return title; + const iconEmoji = normalizeMenuBarNativeString(payload.iconEmoji); + if (iconEmoji) return iconEmoji; + return lastResolvedTrayIconOk ? '' : '\u23f1'; +} + +export function isMenuBarNativeTitleUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTitle: string, +): boolean { + return state.title !== nextTitle; +} + +export function rememberMenuBarNativeTitle( + state: MenuBarNativeUpdateState, + nextTitle: string, +): void { + state.title = nextTitle; +} + +export function getMenuBarNativeTooltip(payload: MenuBarNativeUpdatePayload): string { + return normalizeMenuBarNativeString(payload.tooltip); +} + +export function isMenuBarNativeTooltipUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): boolean { + return nextTooltip.length > 0 && state.tooltip !== nextTooltip; +} + +export function rememberMenuBarNativeTooltip( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): void { + state.tooltip = nextTooltip; +} + +export function getMenuBarNativeItemsKey(items: unknown): string { + return safeJsonStringify(items ?? []); +} + +export function isMenuBarNativeMenuUpdateNeeded( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): boolean { + return state.itemsKey !== nextItemsKey; +} + +export function rememberMenuBarNativeMenu( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): void { + state.itemsKey = nextItemsKey; +} + +function normalizeMenuBarNativeString(value: unknown): string { + if (typeof value === 'string') return value; + if (value == null) return ''; + return String(value); +} + +function safeJsonStringify(value: unknown): string { + try { + const result = JSON.stringify(value); + return typeof result === 'string' ? result : ''; + } catch { + return String(value); + } +} diff --git a/src/main/notes-store.ts b/src/main/notes-store.ts index 39953508..f081167d 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,11 @@ export interface Note { // ─── Cache ────────────────────────────────────────────────────────── let notesCache: Note[] | null = null; +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 +94,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/main/preload.ts b/src/main/preload.ts index eff106ef..79b23d40 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -454,6 +454,7 @@ const electronAPI = { method?: string; headers?: Record; body?: string; + requestId?: string; }): Promise<{ status: number; statusText: string; @@ -463,8 +464,8 @@ const electronAPI = { }> => ipcRenderer.invoke('http-request', options), // Download a URL via Node.js (avoids renderer CORS restrictions for binary CDN downloads) - httpDownloadBinary: (url: string): Promise => - ipcRenderer.invoke('http-download-binary', url), + httpDownloadBinary: (url: string, options?: { requestId?: string }): Promise => + ipcRenderer.invoke('http-download-binary', url, options), // Write raw binary data to a file (used by extension download/install flows) fsWriteBinaryFile: (filePath: string, data: Uint8Array): Promise => @@ -1108,13 +1109,19 @@ const electronAPI = { ollamaOpenDownload: (): Promise => ipcRenderer.invoke('ollama-open-download'), onOllamaPullProgress: (callback: (data: { requestId: string; status: string; digest: string; total: number; completed: number }) => void) => { - ipcRenderer.on('ollama-pull-progress', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-progress', listener); + return () => { ipcRenderer.removeListener('ollama-pull-progress', listener); }; }, onOllamaPullDone: (callback: (data: { requestId: string }) => void) => { - ipcRenderer.on('ollama-pull-done', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-done', listener); + return () => { ipcRenderer.removeListener('ollama-pull-done', listener); }; }, onOllamaPullError: (callback: (data: { requestId: string; error: string }) => void) => { - ipcRenderer.on('ollama-pull-error', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-error', listener); + return () => { ipcRenderer.removeListener('ollama-pull-error', listener); }; }, // ─── Hyper Key ────────────────────────────────────────────────── diff --git a/src/main/sc-asset-protocol.ts b/src/main/sc-asset-protocol.ts new file mode 100644 index 00000000..02cc730a --- /dev/null +++ b/src/main/sc-asset-protocol.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +const CANVAS_LIB_CACHE_CONTROL = 'public, max-age=31536000, immutable'; + +const CANVAS_LIB_MIME_TYPES: Record = { + '.js': 'application/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', + '.png': 'image/png', +}; + +export interface CanvasLibAssetResolution { + filePath: string; + headers: Record; +} + +export type CanvasLibAssetResolveResult = + | { ok: true; asset: CanvasLibAssetResolution } + | { ok: false; response: Response }; + +export type FetchFileResponse = (fileUrl: string) => Promise; + +export function getCanvasLibAssetContentType(filePath: string): string { + return CANVAS_LIB_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +export function resolveCanvasLibAssetRequest( + requestUrl: string, + canvasLibDir: string +): CanvasLibAssetResolveResult { + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + let relPath: string; + try { + relPath = decodeURIComponent(url.pathname || '').replace(/^\/+/, ''); + } catch { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + if (!relPath) { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + const basePath = path.resolve(canvasLibDir); + const filePath = path.resolve(basePath, relPath); + const relativeToBase = path.relative(basePath, filePath); + if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) { + return { ok: false, response: new Response('Not Found', { status: 404 }) }; + } + + return { + ok: true, + asset: { + filePath, + headers: { + 'Content-Type': getCanvasLibAssetContentType(filePath), + 'Cache-Control': CANVAS_LIB_CACHE_CONTROL, + }, + }, + }; +} + +export async function serveCanvasLibAssetFromFile( + requestUrl: string, + canvasLibDir: string, + fetchFile: FetchFileResponse +): Promise { + const resolved = resolveCanvasLibAssetRequest(requestUrl, canvasLibDir); + if (!resolved.ok) return resolved.response; + + try { + const upstream = await fetchFile(pathToFileURL(resolved.asset.filePath).toString()); + if (!upstream.ok) { + return new Response('Not Found', { status: 404 }); + } + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: resolved.asset.headers, + }); + } catch { + return new Response('Not Found', { status: 404 }); + } +} diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..f54255a9 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -33,6 +33,8 @@ export interface ScriptCommandInfo { refreshTime?: string; interval?: string; currentDirectoryPath?: string; + interpreter?: string; + interpreterArgs?: string[]; needsConfirmation: boolean; arguments: ScriptArgumentDefinition[]; keywords: string[]; @@ -55,8 +57,12 @@ export interface ScriptExecutionResult { const CACHE_TTL_MS = 12_000; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; // 2MB const DEFAULT_TIMEOUT_MS = 60_000; +const SCRIPT_COMMAND_HEADER_MAX_LINES = 120; +const SCRIPT_COMMAND_HEADER_MAX_BYTES = 256 * 1024; +const SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES = 8 * 1024; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; +const iconDataUrlCache = new Map(); function getSuperCmdScriptsDir(): string { const dir = path.join(app.getPath('userData'), 'script-commands'); @@ -125,6 +131,33 @@ function fileToDataUrl(filePath: string): string | undefined { } } +function getIconFileSignature(iconPath: string): string | null { + try { + const stat = fs.statSync(iconPath); + if (!stat.isFile()) return null; + return `${stat.size}:${stat.mtimeMs}`; + } catch { + return null; + } +} + +function getCachedIconDataUrl(iconPath: string): string | undefined { + const signature = getIconFileSignature(iconPath); + if (!signature) { + iconDataUrlCache.delete(iconPath); + return undefined; + } + + const cached = iconDataUrlCache.get(iconPath); + if (cached && cached.signature === signature) { + return cached.dataUrl; + } + + const dataUrl = fileToDataUrl(iconPath); + iconDataUrlCache.set(iconPath, { signature, dataUrl }); + return dataUrl; +} + function resolveIcon( rawIcon: string, scriptDir: string @@ -144,9 +177,8 @@ function resolveIcon( const iconPath = path.isAbsolute(expanded) ? expanded : path.resolve(scriptDir, expanded); - if (!fs.existsSync(iconPath)) return {}; - const dataUrl = fileToDataUrl(iconPath); + const dataUrl = getCachedIconDataUrl(iconPath); if (dataUrl) return { iconDataUrl: dataUrl }; return {}; } @@ -302,24 +334,66 @@ function discoverScriptFiles(rootDir: string): string[] { return out; } -function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { - const scriptPath = path.resolve(filePath); - const scriptDir = path.dirname(scriptPath); +function countLineBreaks(buffer: Buffer, length: number): number { + let count = 0; + for (let i = 0; i < length; i += 1) { + if (buffer[i] === 10) count += 1; + } + return count; +} - let raw = ''; +function readScriptCommandHeader(filePath: string): string | null { + let fd: number | null = null; try { - raw = fs.readFileSync(scriptPath, 'utf-8'); + fd = fs.openSync(filePath, 'r'); + const chunks: Buffer[] = []; + let totalBytes = 0; + let lineBreaks = 0; + + while ( + totalBytes < SCRIPT_COMMAND_HEADER_MAX_BYTES && + lineBreaks < SCRIPT_COMMAND_HEADER_MAX_LINES + ) { + const bytesToRead = Math.min( + SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES, + SCRIPT_COMMAND_HEADER_MAX_BYTES - totalBytes + ); + if (bytesToRead <= 0) break; + + const chunk = Buffer.allocUnsafe(bytesToRead); + const bytesRead = fs.readSync(fd, chunk, 0, bytesToRead, totalBytes); + if (bytesRead <= 0) break; + + chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)); + totalBytes += bytesRead; + lineBreaks += countLineBreaks(chunk, bytesRead); + } + + return Buffer.concat(chunks, totalBytes).toString('utf8'); } catch { return null; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch {} + } } +} + +function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { + const scriptPath = path.resolve(filePath); + const scriptDir = path.dirname(scriptPath); + + const raw = readScriptCommandHeader(scriptPath); + if (raw === null) return null; if (!raw.trim()) return null; const lines = raw.split(/\r?\n/); + const shebang = shebangArgs(lines[0] || ''); const metadata: Record = {}; const argumentDefs = new Map(); - for (const line of lines.slice(0, 120)) { + for (const line of lines.slice(0, SCRIPT_COMMAND_HEADER_MAX_LINES)) { const match = line.match(/^\s*(#|\/\/|--)\s*@raycast\.([A-Za-z0-9]+)\s*(.*)$/); if (!match) continue; const key = String(match[2] || '').trim(); @@ -393,6 +467,8 @@ function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { refreshTime, interval: mode === 'inline' ? refreshTime : undefined, currentDirectoryPath, + interpreter: shebang[0], + interpreterArgs: shebang.slice(1), needsConfirmation, arguments: argumentsList, keywords: Array.from(new Set(keywords)), @@ -497,9 +573,7 @@ export async function executeScriptCommand( return { missingArguments: missing, command: cmd }; } - const source = fs.readFileSync(cmd.scriptPath, 'utf-8'); - const firstLine = source.split(/\r?\n/)[0] || ''; - const shebang = shebangArgs(firstLine); + fs.accessSync(cmd.scriptPath, fs.constants.R_OK); const env = { ...process.env, @@ -514,10 +588,10 @@ export async function executeScriptCommand( const cwd = cmd.currentDirectoryPath || cmd.scriptDir; const spawnCommand = - shebang.length > 0 ? shebang[0] : '/bin/bash'; + cmd.interpreter ? cmd.interpreter : '/bin/bash'; const spawnArgs = - shebang.length > 0 - ? [...shebang.slice(1), cmd.scriptPath, ...args] + cmd.interpreter + ? [...(cmd.interpreterArgs || []), cmd.scriptPath, ...args] : [cmd.scriptPath, ...args]; const run = await new Promise<{ @@ -530,6 +604,7 @@ export async function executeScriptCommand( let settled = false; let stdoutBytes = 0; let stderrBytes = 0; + let timeout: ReturnType | null = null; const proc = spawn(spawnCommand, spawnArgs, { cwd, @@ -540,6 +615,10 @@ export async function executeScriptCommand( const finalize = (payload: { stdout: string; stderr: string; exitCode: number }) => { if (settled) return; settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } resolve(payload); }; @@ -575,7 +654,7 @@ export async function executeScriptCommand( } }); - const timeout = setTimeout(() => { + timeout = setTimeout(() => { if (settled) return; try { proc.kill(); } catch {} finalize({ @@ -586,7 +665,6 @@ export async function executeScriptCommand( }, timeoutMs); proc.on('close', (code: number | null) => { - clearTimeout(timeout); finalize({ stdout, stderr, @@ -595,7 +673,6 @@ export async function executeScriptCommand( }); proc.on('error', (error: Error) => { - clearTimeout(timeout); finalize({ stdout, stderr: `${stderr}\n${error.message}`, diff --git a/src/main/whisper-transcribe-file.ts b/src/main/whisper-transcribe-file.ts new file mode 100644 index 00000000..fabaf1cb --- /dev/null +++ b/src/main/whisper-transcribe-file.ts @@ -0,0 +1,197 @@ +import * as path from 'path'; +import type { AppSettings } from './settings-store'; + +export type WhisperFileTranscriptionProvider = + | 'parakeet' + | 'qwen3' + | 'whispercpp' + | 'openai' + | 'elevenlabs' + | 'mistral' + | 'native'; + +export interface WhisperFileTranscriptionPlan { + provider: WhisperFileTranscriptionProvider; + model: string; +} + +export interface WhisperFileSystem { + existsSync(filePath: string): boolean; + readFileSync(filePath: string): Buffer; + unlinkSync(filePath: string): void; + rmdirSync(filePath: string, options?: { recursive?: boolean }): void; +} + +type WhisperCppModelStatus = { + state: string; +}; + +type BufferTranscriber = (opts: { + audioBuffer: Buffer; + language?: string; + mimeType?: string; +}) => Promise; + +type CloudBufferTranscriber = (opts: { + audioBuffer: Buffer; + apiKey: string; + model: string; + language?: string; + mimeType?: string; +}) => Promise; + +export interface TranscribeWhisperAudioFileDeps { + fs: WhisperFileSystem; + loadSettings(): AppSettings; + isAIDisabledInSettings(settings: AppSettings): boolean; + normalizeWhisperLanguageCode(rawLanguage?: string): string; + resolveElevenLabsSttModel(model: string): string; + getElevenLabsApiKey(settings: AppSettings): string; + getMistralApiKey(settings: AppSettings): string; + getWhisperCppModelStatus(): WhisperCppModelStatus; + ensureWhisperCppServer(): Promise; + sendWhisperCppRequest(request: Record): Promise; + transcribeAudioWithParakeet: BufferTranscriber; + transcribeAudioWithQwen3: BufferTranscriber; + transcribeAudioWithElevenLabs: CloudBufferTranscriber; + transcribeAudioWithMistralVoxtral: CloudBufferTranscriber; + transcribeAudio: CloudBufferTranscriber; + whisperCppModelName: string; +} + +export function resolveWhisperFileTranscriptionPlan( + sttModel: string | undefined, + deps: { + whisperCppModelName: string; + resolveElevenLabsSttModel(model: string): string; + } +): WhisperFileTranscriptionPlan { + const rawModel = sttModel || ''; + let provider: WhisperFileTranscriptionProvider = 'whispercpp'; + let model = `ggml-${deps.whisperCppModelName}`; + + if (rawModel === 'parakeet') { + provider = 'parakeet'; + model = 'parakeet-tdt-0.6b-v3'; + } else if (rawModel === 'qwen3') { + provider = 'qwen3'; + model = 'qwen3-asr-0.6b'; + } else if (!rawModel || rawModel === 'default' || rawModel === 'whispercpp') { + provider = 'whispercpp'; + model = `ggml-${deps.whisperCppModelName}`; + } else if (rawModel === 'native') { + provider = 'native'; + model = ''; + } else if (rawModel.startsWith('openai-')) { + provider = 'openai'; + model = rawModel.slice('openai-'.length); + } else if (rawModel.startsWith('elevenlabs-')) { + provider = 'elevenlabs'; + model = deps.resolveElevenLabsSttModel(rawModel); + } else if (rawModel.startsWith('mistral-')) { + provider = 'mistral'; + model = rawModel.slice('mistral-'.length) || 'voxtral-mini-latest'; + } else if (rawModel) { + model = rawModel; + } + + return { provider, model }; +} + +export function shouldReadWhisperFileAudioBuffer(provider: WhisperFileTranscriptionProvider): boolean { + return provider === 'parakeet' + || provider === 'qwen3' + || provider === 'openai' + || provider === 'elevenlabs' + || provider === 'mistral'; +} + +function cleanupWhisperFileAudioPath(fs: WhisperFileSystem, audioPath: string): void { + try { fs.unlinkSync(audioPath); } catch {} + try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} +} + +export async function transcribeWhisperAudioFile(params: { + audioPath: string; + options?: { language?: string }; + deps: TranscribeWhisperAudioFileDeps; +}): Promise { + const { audioPath, options, deps } = params; + const s = deps.loadSettings(); + if (deps.isAIDisabledInSettings(s)) { + throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); + } + if (s.ai?.whisperEnabled === false) { + throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); + } + + if (!deps.fs.existsSync(audioPath)) { + throw new Error(`Audio file not found: ${audioPath}`); + } + + const { provider, model } = resolveWhisperFileTranscriptionPlan( + s.ai.speechToTextModel || '', + { + whisperCppModelName: deps.whisperCppModelName, + resolveElevenLabsSttModel: deps.resolveElevenLabsSttModel, + } + ); + if (provider === 'native') { + return ''; + } + + const audioBuffer = shouldReadWhisperFileAudioBuffer(provider) + ? deps.fs.readFileSync(audioPath) + : null; + + const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; + const language = deps.normalizeWhisperLanguageCode(rawLang); + + if (provider === 'openai' && !s.ai.openaiApiKey) { + throw new Error('OpenAI API key not configured.'); + } + const elevenLabsApiKey = deps.getElevenLabsApiKey(s); + if (provider === 'elevenlabs' && !elevenLabsApiKey) { + throw new Error('ElevenLabs API key not configured.'); + } + const mistralApiKey = deps.getMistralApiKey(s); + if (provider === 'mistral' && !mistralApiKey) { + throw new Error('Mistral API key not configured.'); + } + + if (provider === 'whispercpp') { + const status = deps.getWhisperCppModelStatus(); + if (status.state === 'downloading') { + throw new Error('Whisper model still downloading.'); + } + if (status.state !== 'downloaded') { + throw new Error('Whisper model not downloaded.'); + } + await deps.ensureWhisperCppServer(); + const result = await deps.sendWhisperCppRequest({ + command: 'transcribe', + file: audioPath, + language, + }); + cleanupWhisperFileAudioPath(deps.fs, audioPath); + return result.text || ''; + } + + if (!audioBuffer) { + throw new Error(`No audio buffer available for ${provider} transcription.`); + } + + const mimeType = 'audio/wav'; + const text = provider === 'parakeet' + ? await deps.transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) + : provider === 'qwen3' + ? await deps.transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) + : provider === 'elevenlabs' + ? await deps.transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) + : provider === 'mistral' + ? await deps.transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) + : await deps.transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); + + cleanupWhisperFileAudioPath(deps.fs, audioPath); + return text; +} diff --git a/src/native/audio-capturer.swift b/src/native/audio-capturer.swift index 752b6e3c..9d8ac447 100644 --- a/src/native/audio-capturer.swift +++ b/src/native/audio-capturer.swift @@ -127,10 +127,15 @@ func writeWaveFile(samples: [Float], sampleRate: Double, toPath path: String) th data.append(contentsOf: withUnsafeBytes(of: UInt32(littleEndian: dataSize)) { Array($0) }) // PCM samples (float32 → int16) + var pcmSamples = [Int16]() + pcmSamples.reserveCapacity(samples.count) for sample in samples { let clamped = max(-1.0, min(1.0, sample)) let intVal = Int16(clamped * Float(Int16.max)) - data.append(contentsOf: withUnsafeBytes(of: Int16(littleEndian: intVal)) { Array($0) }) + pcmSamples.append(Int16(littleEndian: intVal)) + } + pcmSamples.withUnsafeBytes { rawBuffer in + data.append(rawBuffer.bindMemory(to: UInt8.self)) } try data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -279,6 +284,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 +305,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..48ad26c6 100644 --- a/src/native/ax-caret-query.swift +++ b/src/native/ax-caret-query.swift @@ -34,6 +34,17 @@ public struct AXCaretRect { public let tier: String } +public struct AXCaretApplicationContext { + public let pid: pid_t + public let bundleIdentifier: String +} + +public struct AXCaretSessionSnapshot { + public let context: AXCaretApplicationContext + public let focusedElement: AXUIElement? + public let caret: AXCaretRect +} + /// Three-way result so callers can distinguish the security-sensitive case /// from an ordinary AX gap. A plain `AXCaretRect?` cannot express this: /// both "secure field" and "no rect available" would be nil, and the caller @@ -50,6 +61,12 @@ public enum AXCaretResult { case noRect } +public enum AXCaretSessionResult { + case secureField + case snapshot(AXCaretSessionSnapshot) + case noRect(AXCaretApplicationContext?) +} + public enum AXCaretQuery { // PIDs we've already nudged. Avoid re-setting AX opt-in attributes every keystroke. nonisolated(unsafe) private static var nudgedPIDs: Set = [] @@ -66,11 +83,28 @@ public enum AXCaretQuery { /// Query the caret state for the frontmost app's focused text element. public static func current() -> AXCaretResult { + switch currentSession() { + case .secureField: + return .secureField + case .snapshot(let snapshot): + return .rect(snapshot.caret) + case .noRect: + return .noRect + } + } + + /// Query the caret state and return the process/focused element needed to + /// safely cache an active emoji search session. + public static func currentSession() -> AXCaretSessionResult { guard let frontApp = NSWorkspace.shared.frontmostApplication else { dbg("no frontmost app") - return .noRect + return .noRect(nil) } let pid = frontApp.processIdentifier + let context = AXCaretApplicationContext( + pid: pid, + bundleIdentifier: frontApp.bundleIdentifier ?? "" + ) let appElement = AXUIElementCreateApplication(pid) let justNudged = nudgeChromiumAX(appElement: appElement, pid: pid) @@ -93,9 +127,9 @@ public enum AXCaretQuery { } guard focusErr == .success, let focused = focusedRaw else { dbg("kAXFocusedUIElementAttribute failed: err=\(focusErr.rawValue)") - return .noRect + return .noRect(context) } - guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect } + guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect(context) } var element = focused as! AXUIElement let role = copyString(element, kAXRoleAttribute as CFString) ?? "" @@ -121,15 +155,27 @@ public enum AXCaretQuery { } if let r = caretRectViaTextMarker(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker") + )) } if let r = caretRectViaRange(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange") + )) } if let r = caretRectViaElementFrame(element: element) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame") + )) } - return .noRect + return .noRect(context) } // MARK: - Descend focus tree to find the true text leaf @@ -161,9 +207,11 @@ 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() + while queueIndex < queue.count { + 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) { diff --git a/src/native/emoji-caret-session-cache.swift b/src/native/emoji-caret-session-cache.swift new file mode 100644 index 00000000..5612ef33 --- /dev/null +++ b/src/native/emoji-caret-session-cache.swift @@ -0,0 +1,35 @@ +import Foundation +@preconcurrency import ApplicationServices + +public enum EmojiCaretSessionValidation { + case empty + case valid(AXCaretSessionSnapshot) + case invalidated +} + +public struct EmojiCaretSessionCache { + private var snapshot: AXCaretSessionSnapshot? + + public init() {} + + public var isActive: Bool { + snapshot != nil + } + + public mutating func store(_ nextSnapshot: AXCaretSessionSnapshot) { + snapshot = nextSnapshot + } + + public mutating func invalidate() { + snapshot = nil + } + + public mutating func validate(eventTargetPID: pid_t?) -> EmojiCaretSessionValidation { + guard let current = snapshot else { return .empty } + if let targetPID = eventTargetPID, targetPID > 0, targetPID != current.context.pid { + snapshot = nil + return .invalidated + } + return .valid(current) + } +} diff --git a/src/native/emoji-trigger-monitor.swift b/src/native/emoji-trigger-monitor.swift index e2f27dd7..a2e47e84 100644 --- a/src/native/emoji-trigger-monitor.swift +++ b/src/native/emoji-trigger-monitor.swift @@ -21,6 +21,7 @@ var currentQuery = "" var interceptEnabled = false var prefixBuffer = "" // rolling window of recent chars, length ≤ triggerPrefixLen var eventTapRef: CFMachPort? +var caretSessionCache = EmojiCaretSessionCache() // MARK: - JSON output @@ -33,38 +34,68 @@ func emit(_ obj: [String: Any]) { // MARK: - Caret rect + secure-field guard -// Included on every `query` event so the host process can decide whether the -// frontmost app is on the user's exclusion list. Read here (rather than in the -// host) because lsappinfo lookups in the host are stale for keystroke-driven -// flows where the launcher window was never brought forward. -func currentFrontmostBundleId() -> String { - return NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" +func resetTriggerState() { + triggerActive = false + currentQuery = "" + interceptEnabled = false + prefixBuffer = "" + caretSessionCache.invalidate() } -func emitQuery(_ query: String) { - let bundleId = currentFrontmostBundleId() - switch AXCaretQuery.current() { +func dismissTrigger(emitDismiss: Bool = true) { + resetTriggerState() + if emitDismiss { emit(["type": "dismiss"]) } +} + +func eventTargetPID(from event: CGEvent) -> pid_t? { + let rawPID = event.getIntegerValueField(.eventTargetUnixProcessID) + return rawPID > 0 ? pid_t(rawPID) : nil +} + +func sessionValidationPID(from event: CGEvent) -> pid_t? { + if let targetPID = eventTargetPID(from: event) { return targetPID } + guard caretSessionCache.isActive else { return nil } + return NSWorkspace.shared.frontmostApplication?.processIdentifier +} + +func emitQueryPayload(_ query: String, bundleId: String, caret: AXCaretRect?) { + var payload: [String: Any] = [ + "type": "query", + "value": query, + "prefixLen": triggerPrefixLen, + "bundleId": bundleId, + ] + if let caret { + payload["caret"] = ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier] + } + emit(payload) +} + +func emitQuery(_ query: String, eventTargetPID: pid_t?) { + switch caretSessionCache.validate(eventTargetPID: eventTargetPID) { + case .valid(let snapshot): + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + return + case .invalidated: + dismissTrigger() + return + case .empty: + break + } + + switch AXCaretQuery.currentSession() { case .secureField: - triggerActive = false - currentQuery = "" - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) - case .rect(let caret): - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - "caret": ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier], - ]) - case .noRect: - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - ]) + dismissTrigger() + case .snapshot(let snapshot): + if let targetPID = eventTargetPID, targetPID > 0, targetPID != snapshot.context.pid { + dismissTrigger() + return + } + caretSessionCache.store(snapshot) + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + case .noRect(let context): + caretSessionCache.invalidate() + emitQueryPayload(query, bundleId: context?.bundleIdentifier ?? "", caret: nil) } } @@ -131,9 +162,7 @@ func run() { // a partial prefix that could accidentally fire on the next keystroke. if hasCmd || hasCtrl { if triggerActive { - triggerActive = false - currentQuery = "" - emit(["type": "dismiss"]) + dismissTrigger() } prefixBuffer = "" // clear regardless of triggerActive (fix A) return Unmanaged.passUnretained(event) @@ -146,10 +175,7 @@ func run() { // otherwise silently extend the query with extended chars (ü, ©, etc.) // on non-US layouts, which is unintuitive (fix B). if hasAlt && triggerActive { - triggerActive = false - currentQuery = "" - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() return Unmanaged.passUnretained(event) } @@ -159,7 +185,7 @@ func run() { switch keyCode { case 36: emit(["type": "nav", "key": "enter"]); return nil case 53: - triggerActive = false; currentQuery = ""; prefixBuffer = "" + resetTriggerState() emit(["type": "nav", "key": "escape"]) return nil case 48: emit(["type": "nav", "key": "tab"]); return nil @@ -175,13 +201,10 @@ func run() { if triggerActive { if currentQuery.isEmpty { // Backspaced through the entire query back into the prefix — dismiss. - triggerActive = false - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { currentQuery.removeLast() - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Update prefix buffer for non-trigger backspace. @@ -194,8 +217,7 @@ func run() { let chars = extractTypedChars(from: event) if chars.isEmpty { if triggerActive { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } return Unmanaged.passUnretained(event) } @@ -206,15 +228,13 @@ func run() { if isEmojiQueryChar(char) { currentQuery.append(char) if currentQuery.count > 30 { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Non-query char (space, punctuation, …) → dismiss trigger. - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() // Feed this char into the prefix buffer in case it starts the // next trigger (e.g. when trigger prefix contains this char). feedPrefixBuffer(char) @@ -268,8 +288,7 @@ func run() { interceptEnabled = json["enabled"] as? Bool ?? false case "dismiss": if triggerActive { - triggerActive = false; currentQuery = ""; interceptEnabled = false; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } default: break } 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/App.tsx b/src/renderer/src/App.tsx index a49482ba..dc1f1ec8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -111,6 +111,7 @@ import { MAX_INLINE_QUICK_LINK_ARGUMENTS, getQuickLinkIdFromCommandId, } from './utils/launcher-misc'; +import { enqueueBackgroundNoViewRun } from './utils/background-no-view-runs'; import { WEB_SEARCH_ROOT_BANG_PREFIX, buildBangSearchUrl, @@ -310,8 +311,9 @@ const App: React.FC = () => { launchType: 'userInitiated' | 'background' = 'userInitiated', reportStatus = false ) => { - const runId = `${bundle.extensionName || bundle.extName}/${bundle.commandName || bundle.cmdName}/${Date.now()}`; - setBackgroundNoViewRuns((prev) => [...prev, { runId, bundle, launchType, reportStatus }]); + setBackgroundNoViewRuns((prev) => + enqueueBackgroundNoViewRun(prev, bundle, launchType, reportStatus).runs + ); }, [setBackgroundNoViewRuns]); const onExitAiMode = useCallback(() => { @@ -1076,7 +1078,7 @@ const App: React.FC = () => { const pinToggleForCommand = useCallback( async (command: CommandInfo) => { - console.log('[PIN-TOGGLE] called for command:', command?.id, command?.name); + console.log('[PIN-TOGGLE] called for command:', command?.id, command?.title); const currentPinned = pinnedCommandsRef.current; const exists = currentPinned.includes(command.id); console.log('[PIN-TOGGLE] currentPinned:', currentPinned, 'exists:', exists); diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..6c5e9940 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,14 +350,14 @@ 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) { try { - await window.electron.execCommand('/bin/mkdir', ['-p', saveDir], {}); const bytes = new Uint8Array(await captureBlob.arrayBuffer()); await window.electron.fsWriteBinaryFile(savePath, bytes); savedToDisk = true; @@ -349,7 +383,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 +397,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 +621,14 @@ const CameraExtension: React.FC = ({ onClose }) => { {selectedCameraLabel} - {capturePreviewDataUrl ? ( + {capturePreviewUrl ? (
Latest capture(); +type SyncCommandResult = { stdout: string; stderr: string; exitCode: number }; +type RealFsStatPayload = { + exists: boolean; + isDirectory: boolean; + isFile: boolean; + size: number; + mode?: number; + uid?: number; + gid?: number; + dev?: number; + ino?: number; + nlink?: number; + atimeMs?: number; + mtimeMs?: number; + ctimeMs?: number; + birthtimeMs?: number; +}; + +let realNodeFsModule: any | undefined; +let realNodeChildProcessModule: any | undefined; + +function getRealNodeRequire(): ((id: string) => any) | null { + if (!USE_REAL_NODE_BUILTINS) return null; + if (typeof _realNodeRequire === 'function') return _realNodeRequire; + if (typeof window !== 'undefined' && typeof (window as any).__scNodeRequire === 'function') { + return (window as any).__scNodeRequire; + } + return null; +} + +function getRealNodeBuiltin(name: string): any | null { + const realRequire = getRealNodeRequire(); + if (!realRequire) return null; + try { + return realRequire(name); + } catch { + return null; + } +} + +function getRealNodeFsModule(): any | null { + if (realNodeFsModule !== undefined) return realNodeFsModule; + const mod = getRealNodeBuiltin('fs'); + if (mod) realNodeFsModule = mod; + return mod || null; +} + +function getRealNodeChildProcessModule(): any | null { + if (realNodeChildProcessModule !== undefined) return realNodeChildProcessModule; + const mod = getRealNodeBuiltin('child_process'); + if (mod) realNodeChildProcessModule = mod; + return mod || null; +} + +function formatSyncOutput(value: any): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + try { + return BufferPolyfill.from(toUint8Array(value)).toString(); + } catch { + return String(value ?? ''); + } +} + +function buildSyncCommandEnv(options?: { env?: Record }): Record { + const baseEnv = { ...((_realNodeProcess?.env || {}) as Record) }; + const extraPaths = [ + '/opt/homebrew/bin', '/opt/homebrew/sbin', + '/usr/local/bin', '/usr/local/sbin', + '/usr/bin', '/usr/sbin', '/bin', '/sbin', + ]; + const currentPath = (options?.env?.PATH ?? baseEnv.PATH ?? ''); + const augmentedPath = [ + ...extraPaths, + ...String(currentPath).split(':').filter(Boolean), + ].filter((value, index, all) => all.indexOf(value) === index).join(':'); + return { + ...baseEnv, + ...(options?.env || {}), + PATH: augmentedPath, + }; +} + +function runNodeCommandSync( + command: string, + args: string[], + options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } +): SyncCommandResult | null { + const childProcess = getRealNodeChildProcessModule(); + if (!childProcess || typeof childProcess.spawnSync !== 'function') return null; + try { + const spawnOptions: any = { + shell: options?.shell ?? false, + env: buildSyncCommandEnv(options), + cwd: options?.cwd || _realNodeProcess?.cwd?.() || undefined, + input: options?.input, + encoding: 'utf-8', + timeout: 60_000, + }; + const result = options?.shell + ? childProcess.spawnSync([command, ...(args || [])].join(' '), [], { ...spawnOptions, shell: true }) + : childProcess.spawnSync(command, args || [], spawnOptions); + return { + stdout: formatSyncOutput(result?.stdout), + stderr: formatSyncOutput(result?.stderr || result?.error?.message), + exitCode: typeof result?.status === 'number' ? result.status : result?.error ? 1 : 0, + }; + } catch (error: any) { + return { + stdout: '', + stderr: error?.message || 'Failed to execute command', + exitCode: 1, + }; + } +} + +function runExtensionCommandSync( + command: string, + args: string[], + options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } +): SyncCommandResult { + const nodeResult = runNodeCommandSync(command, args, options); + if (nodeResult) return nodeResult; + try { + return (window as any).electron?.execCommandSync?.(command, args, options) || { stdout: '', stderr: '', exitCode: 0 }; + } catch (error: any) { + return { stdout: '', stderr: error?.message || 'Failed to execute command', exitCode: 1 }; + } +} + +function realFileExistsSync(path: string): boolean { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.existsSync === 'function') { + try { + return Boolean(fs.existsSync(path)); + } catch { + return false; + } + } + try { + return (window as any).electron?.fileExistsSync?.(path) ?? false; + } catch { + return false; + } +} + +function readRealFileSyncText(path: string): { data: string | null; error: string | null } { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.readFileSync === 'function') { + try { + return { data: String(fs.readFileSync(path, 'utf-8')), error: null }; + } catch (error: any) { + return { data: null, error: error?.message || String(error) }; + } + } + try { + return (window as any).electron?.readFileSync?.(path) || { data: null, error: 'readFileSync unavailable' }; + } catch (error: any) { + return { data: null, error: error?.message || String(error) }; + } +} + +function toRealFsStatPayload(stat: any): RealFsStatPayload { + return { + exists: true, + isDirectory: typeof stat?.isDirectory === 'function' ? stat.isDirectory() : Boolean(stat?.isDirectory), + isFile: typeof stat?.isFile === 'function' ? stat.isFile() : Boolean(stat?.isFile), + size: Number(stat?.size) || 0, + mode: Number(stat?.mode) || undefined, + uid: Number(stat?.uid) || undefined, + gid: Number(stat?.gid) || undefined, + dev: Number(stat?.dev) || undefined, + ino: Number(stat?.ino) || undefined, + nlink: Number(stat?.nlink) || undefined, + atimeMs: Number(stat?.atimeMs) || undefined, + mtimeMs: Number(stat?.mtimeMs) || undefined, + ctimeMs: Number(stat?.ctimeMs) || undefined, + birthtimeMs: Number(stat?.birthtimeMs) || undefined, + }; +} + +function realStatSync(path: string): RealFsStatPayload { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.statSync === 'function') { + try { + return toRealFsStatPayload(fs.statSync(path)); + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } + } + try { + return (window as any).electron?.statSync?.(path) || { exists: false, isDirectory: false, isFile: false, size: 0 }; + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } +} + function isBareCommandPath(p: string): boolean { if (!p) return false; if (p.includes('/') || p.includes('\\')) return false; @@ -540,7 +760,7 @@ function resolveCommandOnPath(command: string): string | null { if (!isBareCommandPath(command)) return null; if (commandPathCache.has(command)) return commandPathCache.get(command) || null; try { - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( '/bin/zsh', ['-lc', `command -v -- ${JSON.stringify(command)} 2>/dev/null || true`], {} @@ -553,7 +773,7 @@ function resolveCommandOnPath(command: string): string | null { const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']; for (const dir of commonDirs) { const candidate = `${dir}/${command}`; - if ((window as any).electron?.fileExistsSync?.(candidate)) { + if (realFileExistsSync(candidate)) { commandPathCache.set(command, candidate); return candidate; } @@ -572,7 +792,7 @@ function resolveExecutablePath(input: any): string { if (raw.startsWith('/')) { try { - const exists = (window as any).electron?.fileExistsSync?.(raw); + const exists = realFileExistsSync(raw); if (exists) return raw; const base = raw.split('/').filter(Boolean).pop() || ''; if (base) { @@ -705,8 +925,16 @@ function collectVirtualDirectoryEntries(dirPath: string): Map String(entry || '')).filter((entry: string) => entry.length > 0); + } catch { + return []; + } + } try { - const result = (window as any).electron?.execCommandSync?.('/bin/ls', ['-A1', dirPath], {}); + const result = runExtensionCommandSync('/bin/ls', ['-A1', dirPath], {}); if (!result || result.exitCode !== 0) return []; return String(result.stdout || '') .split(/\r?\n/) @@ -729,7 +957,7 @@ async function getRealDirectoryEntriesAsync(dirPath: string): Promise function getEntryKindFromRealStat(path: string): FsEntryKind { try { - const stat = (window as any).electron?.statSync?.(path); + const stat = realStatSync(path); if (!stat?.exists) return 'unknown'; if (stat.isDirectory) return 'directory'; if (stat.isFile) return 'file'; @@ -773,7 +1001,7 @@ function combineDirectoryEntries( } function assertReadableDirectory(path: string, hasEntries: boolean): void { - const stat = (window as any).electron?.statSync?.(path); + const stat = realStatSync(path); if (stat?.exists && !stat.isDirectory) { throw createFsError('ENOTDIR', 'scandir', path); } @@ -819,7 +1047,7 @@ const fsStub: Record = { if (getStoredText(path) !== null) return true; // Fall back to real file system via sync IPC try { - return (window as any).electron?.fileExistsSync?.(path) ?? false; + return realFileExistsSync(path); } catch { return false; } @@ -834,7 +1062,7 @@ const fsStub: Record = { } // Fall back to real file system via sync IPC (for reading extension assets etc.) try { - const result = (window as any).electron?.readFileSync?.(path); + const result = readRealFileSyncText(path); if (result && result.data !== null) { if (opts?.encoding || typeof opts === 'string') return result.data; return BufferPolyfill.from(result.data); @@ -876,7 +1104,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} return fsStatResult(false); @@ -886,7 +1114,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} return fsStatResult(false); @@ -915,7 +1143,7 @@ const fsStub: Record = { const path = resolveFsLookupPath(p); if (getStoredText(path) !== null) return; try { - if ((window as any).electron?.fileExistsSync?.(path)) return; + if (realFileExistsSync(path)) return; } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1007,7 +1235,7 @@ const fsStub: Record = { cb(null, content); } else { // Fall back to real file system - const exists = Boolean((window as any).electron?.fileExistsSync?.(path)); + const exists = realFileExistsSync(path); ((window as any).electron?.readFile?.(path) as Promise) ?.then((data: string) => { if (exists) { @@ -1049,7 +1277,7 @@ const fsStub: Record = { if (getStoredText(path) !== null) cb(null); else { try { - if ((window as any).electron?.fileExistsSync?.(path)) { cb(null); return; } + if (realFileExistsSync(path)) { cb(null); return; } } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1067,7 +1295,7 @@ const fsStub: Record = { return; } try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) { cb(null, fsStatResult(true, result.isDirectory, Number(result.size) || 0, result)); return; @@ -1085,7 +1313,7 @@ const fsStub: Record = { return; } try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) { cb(null, fsStatResult(true, result.isDirectory, Number(result.size) || 0, result)); return; @@ -1131,7 +1359,7 @@ const fsStub: Record = { } // Fall back to real file system try { - const exists = Boolean((window as any).electron?.fileExistsSync?.(path)); + const exists = realFileExistsSync(path); const data = await (window as any).electron?.readFile?.(path); if (exists) { if (opts?.encoding || typeof opts === 'string') return data; @@ -1167,7 +1395,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} throw createFsError('ENOENT', 'stat', path); @@ -1177,7 +1405,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} throw createFsError('ENOENT', 'lstat', path); @@ -1187,7 +1415,7 @@ const fsStub: Record = { const path = resolveFsLookupPath(p); if (getStoredText(path) !== null) return; try { - if ((window as any).electron?.fileExistsSync?.(path)) return; + if (realFileExistsSync(path)) return; } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1798,7 +2026,8 @@ const childProcessStub = { return childProcessStub._isGitInvocation(commandOrFile, execArgs) && childProcessStub._isBenignMissingPathError(message); }, - exec: (...args: any[]) => { + exec: (...args: any[]) => childProcessStub._execWithRegistry(undefined, ...args), + _execWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { // Parse arguments: exec(command[, options][, callback]) const command = args[0]; let options: any = {}; @@ -1813,7 +2042,7 @@ const childProcessStub = { const { file, args: execArgs } = resolveExecShellLaunch(normalizedCommand, options?.shell); let stdout = ''; let stderr = ''; - const spawned = childProcessStub.spawn(file, execArgs, { + const spawned = childProcessStub._spawnWithRegistry(registry, file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd, @@ -1874,7 +2103,7 @@ const childProcessStub = { }, execSync: (command: string) => { const normalizedCommand = rewriteShellCommandForMissingBinary(command); - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( '/bin/zsh', ['-lc', normalizedCommand], { shell: false } @@ -1892,7 +2121,8 @@ const childProcessStub = { } return BufferPolyfill.from(result?.stdout || ''); }, - execFile: (...args: any[]) => { + execFile: (...args: any[]) => childProcessStub._execFileWithRegistry(undefined, ...args), + _execFileWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { // Parse arguments: execFile(file[, args][, options][, callback]) const file = resolveExecutablePath(args[0]); let execArgs: string[] = []; @@ -1915,7 +2145,7 @@ const childProcessStub = { if ((window as any).electron?.spawnProcess) { let stdout = ''; let stderr = ''; - const spawned = childProcessStub.spawn(file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd }); + const spawned = childProcessStub._spawnWithRegistry(registry, file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd }); cp.stdin = spawned.stdin; cp.stdout = spawned.stdout; cp.stderr = spawned.stderr; @@ -1982,11 +2212,11 @@ const childProcessStub = { options = args[1]; } - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd, input: options?.input } - ) || { stdout: '', stderr: '', exitCode: 0 }; + ); if (result.exitCode !== 0) { const stderrOrMsg = String(result?.stderr || ''); @@ -2003,7 +2233,8 @@ const childProcessStub = { if (options?.encoding) return result.stdout || ''; return BufferPolyfill.from(result.stdout || ''); }, - spawn: (...args: any[]) => { + spawn: (...args: any[]) => childProcessStub._spawnWithRegistry(undefined, ...args), + _spawnWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { const file = resolveExecutablePath(args[0]); const spawnArgs = Array.isArray(args[1]) ? args[1] : []; const options = (typeof args[2] === 'object' && args[2]) ? args[2] : {}; @@ -2014,7 +2245,18 @@ const childProcessStub = { // This is the generic solution for all extensions using child_process.spawn with progressive output. let pid: number | null = null; const cleanups: Array<() => void> = []; - const cleanup = () => { cleanups.forEach(fn => fn()); cleanups.length = 0; }; + let untrackLifecycleChildProcess: (() => void) | null = null; + let killWhenPidArrives = false; + let disposedByLifecycle = false; + const cleanup = () => { + if (untrackLifecycleChildProcess) { + const untrack = untrackLifecycleChildProcess; + untrackLifecycleChildProcess = null; + untrack(); + } + cleanups.forEach(fn => fn()); + cleanups.length = 0; + }; let didHandleTerminalEvent = false; type PendingSpawnEvent = | { kind: 'stdout'; p: number; data: any; seq?: number } @@ -2061,6 +2303,7 @@ const childProcessStub = { cp.emit('exit', 1, null); }; const queueOrProcess = (event: PendingSpawnEvent) => { + if (disposedByLifecycle) return; if (pid === null) { pendingEvents.push(event); return; @@ -2114,8 +2357,19 @@ const childProcessStub = { cp.kill = (signal?: string | number) => { cp.killed = true; if (pid !== null) electron.killSpawnProcess?.(pid, signal); + else killWhenPidArrives = true; return pid !== null; }; + if (registry) { + untrackLifecycleChildProcess = trackChildProcess(registry, { + cleanup, + kill: () => { + disposedByLifecycle = true; + cp.kill('SIGTERM'); + }, + getPid: () => pid, + }); + } // Wire up stdin forwarding to the main process const stdinQueue: Array<{ data?: any; end?: boolean }> = []; @@ -2156,10 +2410,15 @@ const childProcessStub = { }).then((result: { pid: number }) => { pid = result.pid; cp.pid = pid; + if (killWhenPidArrives) { + cp.kill('SIGTERM'); + return; + } flushStdinQueue(); flushPendingEvents(); }).catch((err: any) => { cleanup(); + if (disposedByLifecycle) return; const message = String(err?.message || err || 'spawn failed'); cp.stderr.emit('data', BufferPolyfill.from(message)); cp.emit('error', new Error(message)); @@ -2203,11 +2462,11 @@ const childProcessStub = { }, spawnSync: (command: string, spawnArgs?: string[], options?: any) => { const resolvedCommand = resolveExecutablePath(command); - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( resolvedCommand, Array.isArray(spawnArgs) ? spawnArgs : [], { shell: options?.shell ?? false, env: options?.env, cwd: options?.cwd, input: options?.input } - ) || { stdout: '', stderr: '', exitCode: 0 }; + ); const stdoutBuf = BufferPolyfill.from(result.stdout || ''); const stderrBuf = BufferPolyfill.from(result.stderr || ''); @@ -2236,6 +2495,16 @@ const childProcessStub = { fork: () => createStubChildProcess(), }; +function createExtensionChildProcessStub(registry: TimerRegistry | undefined): any { + if (!registry) return childProcessStub; + return { + ...childProcessStub, + exec: (...args: any[]) => childProcessStub._execWithRegistry(registry, ...args), + execFile: (...args: any[]) => childProcessStub._execFileWithRegistry(registry, ...args), + spawn: (...args: any[]) => childProcessStub._spawnWithRegistry(registry, ...args), + }; +} + // ── timers stubs ──────────────────────────────────────────────── const timersStub = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -2828,12 +3097,43 @@ const nodeBuiltinStubs: Record = { if (typeof binaryDownloader !== 'function') { throw new Error('Binary download bridge unavailable'); } - rawBytes = await Promise.race([ - binaryDownloader(resolvedUrl), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Binary download timed out')), 45_000) - ), - ]); + const requestId = createExtensionRuntimeRequestId('axiosBinary'); + const cancelDownload = () => { + (window as any).electron?.cancelHttpRequest?.(requestId); + }; + let timeoutId: ReturnType | undefined; + let abortListener: (() => void) | undefined; + try { + rawBytes = await Promise.race([ + binaryDownloader(resolvedUrl, { requestId }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + cancelDownload(); + reject(new Error('Binary download timed out')); + }, 45_000); + }), + config?.signal + ? new Promise((_, reject) => { + abortListener = () => { + cancelDownload(); + const err = new Error('Binary download aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (config.signal.aborted) { + abortListener(); + return; + } + config.signal.addEventListener('abort', abortListener, { once: true }); + }) + : new Promise(() => {}), + ]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + if (abortListener && config?.signal) { + config.signal.removeEventListener('abort', abortListener); + } + } } catch (e: any) { const err: any = new Error(e?.message || 'Download failed'); err.isAxiosError = true; @@ -3037,6 +3337,11 @@ function isNodeBuiltinRequest(name: string): boolean { return false; } +function shouldUseSuperCmdBuiltinFacade(name: string): boolean { + const normalized = name.startsWith('node:') ? name.slice(5) : name; + return normalized === 'fs' || normalized === 'fs/promises' || normalized === 'child_process'; +} + // Capture real Node globals once, then remove them from globalThis so // extensions can't reach around fakeRequire. Runs at module load, before // any extension code executes. @@ -3246,145 +3551,401 @@ function ensureGlobals() { // fetch bridge — route extension HTTP(S) through main process to avoid CORS. // Keep native fetch for non-HTTP URLs and unsupported body types. - if (!g.__SUPERCMD_NATIVE_FETCH && typeof g.fetch === 'function') { - g.__SUPERCMD_NATIVE_FETCH = g.fetch.bind(g); - } - if (!g.__SUPERCMD_FETCH_PATCHED) { - const nativeFetch = g.__SUPERCMD_NATIVE_FETCH; - const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); - const toHeadersObject = (headersLike: any): Record => { - const out: Record = {}; - if (!headersLike) return out; - try { - const normalized = new Headers(headersLike as HeadersInit); - normalized.forEach((v, k) => { - out[k] = v; - }); - } catch { - if (typeof headersLike === 'object') { - for (const [k, v] of Object.entries(headersLike)) { - out[k] = String(v); - } - } - } - return out; - }; - const normalizeBody = async (body: any): Promise => { - if (body == null) return undefined; - if (typeof body === 'string') return body; - if (body instanceof URLSearchParams) return body.toString(); - if (body instanceof Blob) return await body.text(); - if (typeof body === 'object') return JSON.stringify(body); - return String(body); - }; - - g.fetch = async (input: any, init?: any) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input?.url || String(input ?? ''); - - // Only proxy HTTP(S) requests. - if (!isHttpUrl(url) || !(window as any).electron?.httpRequest) { - return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); - } - - // FormData/streams are not representable via current IPC payload. Fall back. - const requestBody = init?.body; - if ( - requestBody instanceof FormData || - requestBody instanceof ReadableStream || - (typeof requestBody === 'object' && requestBody?.getReader) - ) { - return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); - } - - const method = (init?.method || input?.method || 'GET').toUpperCase(); - const headers = { - ...toHeadersObject(input?.headers), - ...toHeadersObject(init?.headers), - }; - const body = await normalizeBody(requestBody); - - const binaryDownloader = (window as any).electron?.httpDownloadBinary; - const canDownloadBinary = method === 'GET' && typeof binaryDownloader === 'function'; - const ipcRes = await (window as any).electron.httpRequest({ url, method, headers, body }); - - if (!ipcRes || ipcRes.status === 0) { - if (typeof nativeFetch === 'function') { - try { - return await nativeFetch(input, init); - } catch (nativeErr: any) { - const proxyMsg = ipcRes?.statusText || `Failed to fetch ${url}`; - const nativeMsg = nativeErr?.message || String(nativeErr); - throw new TypeError(`${proxyMsg}; native fetch fallback failed: ${nativeMsg}`); - } - } - throw new TypeError(ipcRes?.statusText || `Failed to fetch ${url}`); - } - - const contentType = String( - ipcRes.headers?.['content-type'] || - ipcRes.headers?.['Content-Type'] || - '' - ).toLowerCase(); - const requestAccept = String(headers?.Accept || headers?.accept || '').toLowerCase(); - const looksLikeBinaryUrl = /\.(gif|png|apng|jpe?g|webp|bmp|ico|icns|tiff?|mp3|wav|ogg|aac|m4a|mp4|mov|webm|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|bz2|7z|rar)(?:[?#]|$)/i.test(url); - const isBinaryContentType = - /^image\/(?!svg\+xml)/i.test(contentType) || - /^(audio|video|font)\//i.test(contentType) || - /^application\/(?:octet-stream|pdf|zip|gzip|x-gzip|x-bzip|x-7z-compressed|x-rar-compressed)/i.test(contentType); - const prefersBinaryResponse = requestAccept.includes('image/') || requestAccept.includes('application/octet-stream'); - - let rawBytes: Uint8Array | null = null; - if (canDownloadBinary && (isBinaryContentType || prefersBinaryResponse || looksLikeBinaryUrl)) { - rawBytes = await binaryDownloader(url).catch(() => null as Uint8Array | null); - } - - // Build Response with binary body when available, text otherwise. - const responseBody = rawBytes && rawBytes.length > 0 ? rawBytes : (ipcRes.bodyText ?? ''); - const response = new Response(responseBody, { - status: ipcRes.status, - statusText: ipcRes.statusText || '', - headers: ipcRes.headers || {}, - }); + installExtensionFetchBridge(g); +} - try { - Object.defineProperty(response, 'url', { value: ipcRes.url || url }); - } catch {} +interface TrackedChildProcess { + cleanup: () => void; + kill: () => void; + getPid?: () => number | null; +} - return response; - }; +type ExtensionTimerHandle = any; - g.__SUPERCMD_FETCH_PATCHED = true; - } +interface TrackedEventListener { + target: EventTarget; + type: string; + listener: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; + capture: boolean; } /** - * Per-ExtensionView registry of timer handles created by the extension's - * sandboxed setInterval/setTimeout/requestAnimationFrame. Cleared on unmount - * so a buggy extension (e.g. raycast/timers) cannot leak timers + retained - * fibers into the host renderer. + * Per-ExtensionView registry of lifecycle handles created by the extension's + * sandboxed timers and scoped event targets. Cleared on unmount so a buggy + * extension (e.g. raycast/timers) cannot leak DOM handles + retained fibers + * into the host renderer. */ export interface TimerRegistry { - intervals: Set; - timeouts: Set; - rafs: Set; + intervals: Set; + timeouts: Set; + rafs: Set; + intervalClearers: Map void>; + timeoutClearers: Map void>; + rafClearers: Map void>; + eventListeners: Set; + childProcesses: Set; } export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; + return { + intervals: new Set(), + timeouts: new Set(), + rafs: new Set(), + intervalClearers: new Map(), + timeoutClearers: new Map(), + rafClearers: new Map(), + eventListeners: new Set(), + childProcesses: new Set(), + }; } export function clearTimerRegistry(registry: TimerRegistry): void { - registry.intervals.forEach((id) => window.clearInterval(id)); - registry.timeouts.forEach((id) => window.clearTimeout(id)); - registry.rafs.forEach((id) => window.cancelAnimationFrame(id)); + Array.from(registry.childProcesses).forEach((entry) => { + try { + entry.cleanup(); + } catch {} + try { + entry.kill(); + } catch {} + }); + Array.from(registry.eventListeners).forEach((entry) => { + try { + entry.target.removeEventListener(entry.type, entry.listener, entry.options); + } catch {} + }); + Array.from(registry.intervals).forEach((id) => { + const clear = registry.intervalClearers.get(id) || window.clearInterval.bind(window); + clear(id); + }); + Array.from(registry.timeouts).forEach((id) => { + const clear = registry.timeoutClearers.get(id) || window.clearTimeout.bind(window); + clear(id); + }); + Array.from(registry.rafs).forEach((id) => { + const clear = registry.rafClearers.get(id) || window.cancelAnimationFrame.bind(window); + clear(id); + }); + registry.eventListeners.clear(); registry.intervals.clear(); registry.timeouts.clear(); registry.rafs.clear(); + registry.intervalClearers.clear(); + registry.timeoutClearers.clear(); + registry.rafClearers.clear(); + registry.childProcesses.clear(); +} + +function getEventListenerCapture(options?: boolean | AddEventListenerOptions): boolean { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function findTrackedEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): TrackedEventListener | null { + if (!registry || !listener) return null; + const capture = getEventListenerCapture(options); + for (const entry of registry.eventListeners) { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + return entry; + } + } + return null; +} + +function trackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): void { + if (!registry || !listener) return; + if (findTrackedEventListener(registry, target, type, listener, options)) return; + registry.eventListeners.add({ + target, + type, + listener, + options, + capture: getEventListenerCapture(options), + }); +} + +function untrackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions +): void { + if (!registry || !listener) return; + const capture = getEventListenerCapture(options); + Array.from(registry.eventListeners).forEach((entry) => { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + registry.eventListeners.delete(entry); + } + }); +} + +function shouldBindHostFunction(prop: string | symbol, value: Function): boolean { + if (typeof prop === 'string' && /^[A-Z]/.test(prop)) return false; + try { + if (/^class\s/.test(Function.prototype.toString.call(value))) return false; + } catch {} + return true; +} + +function getWindowPeer(hostWindow: any, scopedWindow: any, prop: 'top' | 'parent' | 'opener'): any { + try { + const value = hostWindow?.[prop]; + return value === hostWindow ? scopedWindow : value; + } catch { + return scopedWindow; + } +} + +function createScopedHostProxy( + host: any, + registry: TimerRegistry | undefined, + kind: 'window' | 'document', + getScopedWindow: () => any, + getScopedDocument: () => any, + timerApi?: { + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + } +): any { + const boundFunctions = new WeakMap(); + const target = {}; + + return new Proxy(target, { + get(_target, prop) { + if (kind === 'window') { + if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'global') return getScopedWindow(); + if (prop === 'top' || prop === 'parent' || prop === 'opener') return getWindowPeer(host, getScopedWindow(), prop); + if (prop === 'document') return getScopedDocument(); + if (prop === 'setInterval') return timerApi?.setInterval; + if (prop === 'clearInterval') return timerApi?.clearInterval; + if (prop === 'setTimeout') return timerApi?.setTimeout; + if (prop === 'clearTimeout') return timerApi?.clearTimeout; + if (prop === 'requestAnimationFrame') return timerApi?.requestAnimationFrame; + if (prop === 'cancelAnimationFrame') return timerApi?.cancelAnimationFrame; + } else if (prop === 'defaultView') { + return getScopedWindow(); + } + + if (prop === 'addEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => { + host.addEventListener(type, listener as any, options); + trackEventListener(registry, host, type, listener, options); + }; + } + + if (prop === 'removeEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => { + host.removeEventListener(type, listener as any, options); + untrackEventListener(registry, host, type, listener, options); + }; + } + + const value = Reflect.get(host, prop, host); + if (typeof value !== 'function' || !shouldBindHostFunction(prop, value)) return value; + const cached = boundFunctions.get(value); + if (cached) return cached; + const bound = value.bind(host) as Function; + boundFunctions.set(value, bound); + return bound; + }, + set(_target, prop, value) { + return Reflect.set(host, prop, value, host); + }, + has(_target, prop) { + return prop in host; + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(host, prop); + }, + defineProperty(_target, prop, descriptor) { + return Reflect.defineProperty(host, prop, descriptor); + }, + getOwnPropertyDescriptor(_target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(host, prop); + if (!descriptor) return undefined; + return { ...descriptor, configurable: true }; + }, + ownKeys() { + return Reflect.ownKeys(host); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(host); + }, + }); +} + +export function createExtensionLifecycleScope( + registry: TimerRegistry | undefined, + hostWindow: any = window, + hostDocument: any = hostWindow?.document ?? document +): { + scopedWindow: any; + scopedDocument: any; + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + setImmediate: (callback: Function, ...args: any[]) => ExtensionTimerHandle; + clearImmediate: (id?: ExtensionTimerHandle) => void; +} { + const nativeSetInterval = hostWindow.setInterval.bind(hostWindow); + const nativeClearInterval = hostWindow.clearInterval.bind(hostWindow); + const nativeSetTimeout = hostWindow.setTimeout.bind(hostWindow); + const nativeClearTimeout = hostWindow.clearTimeout.bind(hostWindow); + const nativeRequestAnimationFrame = hostWindow.requestAnimationFrame.bind(hostWindow); + const nativeCancelAnimationFrame = hostWindow.cancelAnimationFrame.bind(hostWindow); + + const trackInterval = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + const id = nativeSetInterval(handler as any, timeout as any, ...args); + registry?.intervals.add(id); + registry?.intervalClearers.set(id, nativeClearInterval); + return id; + }; + const trackClearInterval = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.intervals.delete(id); + registry?.intervalClearers.delete(id); + } + nativeClearInterval(id); + }; + const untrackTimeout = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.timeouts.delete(id); + registry?.timeoutClearers.delete(id); + } + }; + const runTimeoutHandler = (handler: TimerHandler, thisArg: any, callbackArgs: any[]) => { + if (typeof handler === 'function') { + return handler.apply(thisArg, callbackArgs); + } + const source = String(handler); + if (typeof hostWindow.eval === 'function') { + return hostWindow.eval(source); + } + return Function(source).call(thisArg); + }; + const trackTimeout = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + let id: ExtensionTimerHandle | undefined; + const wrappedHandler = function (this: any, ...callbackArgs: any[]) { + untrackTimeout(id); + return runTimeoutHandler(handler, this, callbackArgs); + }; + id = nativeSetTimeout(wrappedHandler as any, timeout as any, ...args); + registry?.timeouts.add(id); + registry?.timeoutClearers.set(id, nativeClearTimeout); + return id; + }; + const trackClearTimeout = (id?: ExtensionTimerHandle) => { + untrackTimeout(id); + nativeClearTimeout(id); + }; + const untrackRaf = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.rafs.delete(id); + registry?.rafClearers.delete(id); + } + }; + const trackRaf = (callback: FrameRequestCallback) => { + if (typeof callback !== 'function') { + return nativeRequestAnimationFrame(callback as any); + } + let id: ExtensionTimerHandle | undefined; + const wrappedCallback = function (this: any, timestamp: DOMHighResTimeStamp) { + untrackRaf(id); + callback.call(this, timestamp); + }; + id = nativeRequestAnimationFrame(wrappedCallback); + registry?.rafs.add(id); + registry?.rafClearers.set(id, nativeCancelAnimationFrame); + return id; + }; + const trackCancelRaf = (id?: ExtensionTimerHandle) => { + untrackRaf(id); + nativeCancelAnimationFrame(id); + }; + const trackSetImmediate = (callback: Function, ...args: any[]) => + trackTimeout(() => callback(...args), 0); + const trackClearImmediate = (id?: ExtensionTimerHandle) => trackClearTimeout(id); + + const timerApi = { + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + }; + + let scopedWindow: any; + let scopedDocument: any; + scopedDocument = createScopedHostProxy( + hostDocument, + registry, + 'document', + () => scopedWindow, + () => scopedDocument + ); + scopedWindow = createScopedHostProxy( + hostWindow, + registry, + 'window', + () => scopedWindow, + () => scopedDocument, + timerApi + ); + + return { + scopedWindow, + scopedDocument, + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + setImmediate: trackSetImmediate, + clearImmediate: trackClearImmediate, + }; +} + +export function trackChildProcess( + registry: TimerRegistry | undefined, + childProcess: TrackedChildProcess +): () => void { + if (!registry) return () => {}; + registry.childProcesses.add(childProcess); + return () => { + registry.childProcesses.delete(childProcess); + }; } /** @@ -3571,7 +4132,8 @@ end tell`.trim(); function loadExtensionExport( code: string, extensionPath?: string, - timerRegistry?: TimerRegistry + timerRegistry?: TimerRegistry, + extensionIdentity = extensionPath || 'unknown-extension' ): Function | null { const patchSchemeDynamicImports = (sourceCode: string): string => { // Extension bundles may emit dynamic imports for native Raycast bridges @@ -3607,14 +4169,11 @@ function loadExtensionExport( // This is the critical bridge between extension code and the // SuperCmd renderer environment. Every module an extension // might `require()` must be handled here. - // - // IMPORTANT: We track React requires to verify the same instance is always returned. let reactRequireCount = 0; const fakeRequire: any = (name: string): any => { - // Track all requires for debugging if (name === 'react' || name.startsWith('react/') || name === 'react-dom') { reactRequireCount++; - console.log(`[fakeRequire] #${reactRequireCount} require("${name}")`); + logExtensionRuntimeDebug(`[fakeRequire] #${reactRequireCount} require("${name}")`); } // ── React & friends ───────────────────────────────────── // CRITICAL: Extensions MUST use the same React instance as the host. @@ -3626,14 +4185,14 @@ function loadExtensionExport( switch (name) { case 'react': { // Return React directly - the exact same module the host uses - console.log('[fakeRequire] Providing React directly'); + logExtensionRuntimeDebug('[fakeRequire] Providing React directly'); (globalThis as any).__SUPERCMD_REACT = React; return React; } case 'react-dom': case 'react-dom/client': - console.log('[fakeRequire] Providing ReactDOM'); - console.log('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot); + logExtensionRuntimeDebug('[fakeRequire] Providing ReactDOM'); + logExtensionRuntimeDebug('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot); return ReactDOM; case 'react-dom/server': return reactDomServerStub; @@ -3641,9 +4200,9 @@ function loadExtensionExport( case 'react/jsx-dev-runtime': { // Return the actual jsx-runtime to ensure JSX creates elements // using the same React.createElement - console.log('[fakeRequire] Providing jsx-runtime'); - console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); - console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); + logExtensionRuntimeDebug('[fakeRequire] Providing jsx-runtime'); + logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); + logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); return JsxRuntime; } @@ -3747,6 +4306,14 @@ function loadExtensionExport( // Prefer real Node (via the preload bridge) when the hosting window // has Node enabled. Falls back to the stub if the module isn't a // recognised built-in, or if real require throws. + if (shouldUseSuperCmdBuiltinFacade(name)) { + const normalizedBuiltinName = name.startsWith('node:') ? name.slice(5) : name; + if (normalizedBuiltinName === 'child_process') { + return createExtensionChildProcessStub(timerRegistry); + } + const facade = nodeBuiltinStubs[name] || nodeBuiltinStubs[`node:${name}`]; + if (facade) return facade; + } const realModule = tryRealNodeRequire(name); if (realModule !== undefined) { return realModule; @@ -3909,44 +4476,10 @@ function loadExtensionExport( throw new Error(`Unsupported dynamic import in extension runtime: ${id}`); }; - // Sandboxed timer APIs — passed as named parameters so the extension's - // bundle resolves bare `setInterval`/`setTimeout`/`requestAnimationFrame` - // references against this scope instead of the host `window`. Handles are - // tracked in `timerRegistry` so the consumer (ExtensionView) can clear - // anything still pending on unmount, defending against extensions that - // forget their own cleanup (e.g. raycast/timers). - const trackInterval = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setInterval(cb as any, ms as any, ...rest); - timerRegistry?.intervals.add(id); - return id; - }; - const trackClearInterval = (id: any) => { - if (typeof id === 'number') timerRegistry?.intervals.delete(id); - window.clearInterval(id); - }; - const trackTimeout = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setTimeout(cb as any, ms as any, ...rest); - timerRegistry?.timeouts.add(id); - return id; - }; - const trackClearTimeout = (id: any) => { - if (typeof id === 'number') timerRegistry?.timeouts.delete(id); - window.clearTimeout(id); - }; - const trackRaf = (cb: FrameRequestCallback) => { - const id = window.requestAnimationFrame(cb); - timerRegistry?.rafs.add(id); - return id; - }; - const trackCancelRaf = (id: any) => { - if (typeof id === 'number') timerRegistry?.rafs.delete(id); - window.cancelAnimationFrame(id); - }; - // setImmediate / clearImmediate are Node-isms not on `window` — polyfill - // via setTimeout(0) and route through the same registry. - const trackSetImmediate = (cb: Function, ...args: any[]) => - trackTimeout(() => cb(...args), 0); - const trackClearImmediate = (id: any) => trackClearTimeout(id); + // Sandboxed lifecycle APIs — passed as named parameters so the extension's + // bundle resolves bare timers and `window.*`/`document.*` event listeners + // against this ExtensionView instance instead of the host renderer globals. + const lifecycleScope = createExtensionLifecycleScope(timerRegistry); // Execute the CJS bundle in a function scope. // We pass all the standard CJS arguments plus `process`, `Buffer`, @@ -3962,28 +4495,12 @@ function loadExtensionExport( // anyway because requests are routed through the main process). Code // that genuinely needs navigator can still reach it via // `globalThis.navigator` or `window.navigator`. - const fn = new Function( - 'exports', - 'require', - 'module', - '__filename', - '__dirname', - 'process', - 'Buffer', - 'global', - 'globalThis', - 'setImmediate', - 'clearImmediate', - 'setInterval', - 'clearInterval', - 'setTimeout', - 'clearTimeout', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'navigator', - '__scDynamicImport', - executableCode - ); + const { wrapper: fn, cacheHit } = getCompiledExtensionWrapper({ + extensionIdentity, + code, + executableCode, + }); + logExtensionRuntimeDebug(`[loadExtensionExport] Wrapper cache ${cacheHit ? 'hit' : 'miss'} for ${extensionIdentity}`); fn( moduleExports, @@ -3993,16 +4510,18 @@ function loadExtensionExport( '/extension', bundleProcess, bundleBuffer, - globalThis, - globalThis, - trackSetImmediate, - trackClearImmediate, - trackInterval, - trackClearInterval, - trackTimeout, - trackClearTimeout, - trackRaf, - trackCancelRaf, + lifecycleScope.scopedWindow, + lifecycleScope.scopedWindow, + lifecycleScope.setImmediate, + lifecycleScope.clearImmediate, + lifecycleScope.setInterval, + lifecycleScope.clearInterval, + lifecycleScope.setTimeout, + lifecycleScope.clearTimeout, + lifecycleScope.requestAnimationFrame, + lifecycleScope.cancelAnimationFrame, + lifecycleScope.scopedWindow, + lifecycleScope.scopedDocument, undefined, // navigator — see comment above scDynamicImport, ); @@ -4011,10 +4530,12 @@ function loadExtensionExport( const exported = fakeModule.exports.default || fakeModule.exports; - console.log('[loadExtensionExport] Extension loaded successfully'); - console.log('[loadExtensionExport] Exported type:', typeof exported); - console.log('[loadExtensionExport] Exported name:', exported?.name); - console.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + if (isExtensionRuntimeDebugLoggingEnabled()) { + console.debug('[loadExtensionExport] Extension loaded successfully'); + console.debug('[loadExtensionExport] Exported type:', typeof exported); + console.debug('[loadExtensionExport] Exported name:', exported?.name); + console.debug('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + } if (typeof exported === 'function') { return exported; @@ -4165,9 +4686,8 @@ const ViewRenderer: React.FC<{ fallbackText, launchType = 'userInitiated', }) => { - // Simple test that hooks work here const [test] = useState('ok'); - console.log('[ViewRenderer] Hooks work here, rendering extension...'); + logExtensionRuntimeDebug('[ViewRenderer] Hooks work here, rendering extension...'); // Pass standard Raycast props: arguments (command arguments) and launchType return React.createElement(Component, { arguments: launchArguments, @@ -4239,6 +4759,11 @@ const ExtensionView: React.FC = ({ mode, ]); + const extensionWrapperIdentity = useMemo( + () => [owner, extensionName, commandName, extensionPath].map((part) => String(part || '')).join('\0'), + [owner, extensionName, commandName, extensionPath] + ); + // Set extension context before loading (so getPreferenceValues etc. work) useEffect(() => { setExtensionContext(extensionCtx); @@ -4266,9 +4791,9 @@ const ExtensionView: React.FC = ({ // Load under the extension's scoped context so other async extension work // cannot leak a different context into this bundle. return withExtensionContext(extensionCtx, () => - loadExtensionExport(code, extensionPath, timerRegistryRef.current) + loadExtensionExport(code, extensionPath, timerRegistryRef.current, extensionWrapperIdentity) ); - }, [code, buildError, extensionCtx, extensionPath]); + }, [code, buildError, extensionCtx, extensionPath, extensionWrapperIdentity]); // Is this a no-view command? Trust the mode from package.json. // NOTE: 'menu-bar' commands ARE React components (they use hooks), diff --git a/src/renderer/src/QuickLinkManager.tsx b/src/renderer/src/QuickLinkManager.tsx index 2f749757..deaea4c2 100644 --- a/src/renderer/src/QuickLinkManager.tsx +++ b/src/renderer/src/QuickLinkManager.tsx @@ -1286,7 +1286,7 @@ const QuickLinkManager: React.FC = ({ onClose, initialVie const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineDynamicInputRefs = useRef>([]); + const inlineDynamicInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index 3e2e8c65..e5ef51dd 100644 --- a/src/renderer/src/SnippetManager.tsx +++ b/src/renderer/src/SnippetManager.tsx @@ -432,7 +432,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineArgumentInputRefs = useRef>([]); + const inlineArgumentInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/camera-capture.ts b/src/renderer/src/camera-capture.ts new file mode 100644 index 00000000..7be85a95 --- /dev/null +++ b/src/renderer/src/camera-capture.ts @@ -0,0 +1,73 @@ +export interface CapturePreviewUrlApi { + createObjectURL: (blob: Blob) => 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/components/HiddenExtensionRunners.tsx b/src/renderer/src/components/HiddenExtensionRunners.tsx index 6f21f494..db83a2c5 100644 --- a/src/renderer/src/components/HiddenExtensionRunners.tsx +++ b/src/renderer/src/components/HiddenExtensionRunners.tsx @@ -1,7 +1,8 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useEffect, useMemo } from 'react'; import ExtensionView from '../ExtensionView'; import type { BackgroundNoViewRun, MenuBarEntry } from '../hooks/useMenuBarExtensions'; import { NOOP_ON_CLOSE } from '../utils/launcher-misc'; +import { removeBackgroundNoViewRun } from '../utils/background-no-view-runs'; type HiddenExtensionRunnersProps = { menuBarExtensions: MenuBarEntry[]; @@ -9,11 +10,55 @@ type HiddenExtensionRunnersProps = { setBackgroundNoViewRuns: React.Dispatch>; }; +type BackgroundNoViewRunnerProps = { + run: BackgroundNoViewRun; + onRunFinished: (runId: string) => void; +}; + +const BackgroundNoViewRunner: React.FC = ({ run, onRunFinished }) => { + const closeRun = useCallback(() => { + onRunFinished(run.runId); + }, [onRunFinished, run.runId]); + + return ( + + ); +}; + const HiddenExtensionRunners: React.FC = ({ menuBarExtensions, backgroundNoViewRuns, setBackgroundNoViewRuns, }) => { + const onBackgroundNoViewRunFinished = useCallback((runId: string) => { + setBackgroundNoViewRuns((prev) => removeBackgroundNoViewRun(prev, runId)); + }, [setBackgroundNoViewRuns]); + + useEffect(() => { + return () => { + setBackgroundNoViewRuns((prev) => (prev.length === 0 ? prev : [])); + }; + }, [setBackgroundNoViewRuns]); + const menuBarRunner = useMemo(() => { if (menuBarExtensions.length === 0) return null; return ( @@ -49,33 +94,15 @@ const HiddenExtensionRunners: React.FC = ({ return (
{backgroundNoViewRuns.map((run) => ( - { - setBackgroundNoViewRuns((prev) => prev.filter((item) => item.runId !== run.runId)); - }} + run={run} + onRunFinished={onBackgroundNoViewRunFinished} /> ))}
); - }, [backgroundNoViewRuns, setBackgroundNoViewRuns]); + }, [backgroundNoViewRuns, onBackgroundNoViewRunFinished]); return ( <> diff --git a/src/renderer/src/components/LauncherCommandList.tsx b/src/renderer/src/components/LauncherCommandList.tsx index d6873475..9a1e1921 100644 --- a/src/renderer/src/components/LauncherCommandList.tsx +++ b/src/renderer/src/components/LauncherCommandList.tsx @@ -9,6 +9,176 @@ export type LauncherCommandSection = { items: CommandInfo[]; }; +const COMMAND_ROW_HEIGHT = 38; +const SECTION_HEADER_HEIGHT = 26; +const CALCULATOR_CARD_HEIGHT = 124; +const DEFAULT_VIEWPORT_HEIGHT = 420; +const VIRTUALIZATION_THRESHOLD = 120; +const VIRTUALIZATION_OVERSCAN_PX = COMMAND_ROW_HEIGHT * 6; + +type LauncherVirtualEntry = + | { + kind: 'calculator'; + key: string; + top: number; + height: number; + absoluteIndex: number; + } + | { + kind: 'section'; + key: string; + title: string; + top: number; + height: number; + } + | { + kind: 'command'; + key: string; + command: CommandInfo; + flatIndex: number; + absoluteIndex: number; + top: number; + height: number; + }; + +type LauncherVirtualList = { + entries: LauncherVirtualEntry[]; + totalHeight: number; +}; + +function buildVirtualEntries( + sections: LauncherCommandSection[], + calcResult: CalcResult | null, + calcOffset: number +): LauncherVirtualList { + const entries: LauncherVirtualEntry[] = []; + let top = 0; + let flatIndexCursor = 0; + + if (calcResult) { + entries.push({ + kind: 'calculator', + key: 'calculator', + top, + height: CALCULATOR_CARD_HEIGHT, + absoluteIndex: 0, + }); + top += CALCULATOR_CARD_HEIGHT; + } + + sections.forEach((section, sectionIndex) => { + const sectionStartIndex = flatIndexCursor; + if (section.title) { + entries.push({ + kind: 'section', + key: `section-${sectionIndex}-${sectionStartIndex}-${section.title}`, + title: section.title, + top, + height: SECTION_HEADER_HEIGHT, + }); + top += SECTION_HEADER_HEIGHT; + } + + section.items.forEach((command, itemIndex) => { + const flatIndex = sectionStartIndex + itemIndex; + entries.push({ + kind: 'command', + key: command.id, + command, + flatIndex, + absoluteIndex: flatIndex + calcOffset, + top, + height: COMMAND_ROW_HEIGHT, + }); + top += COMMAND_ROW_HEIGHT; + }); + flatIndexCursor += section.items.length; + }); + + return { entries, totalHeight: top }; +} + +function findEntryIndexAtOffset(entries: LauncherVirtualEntry[], offset: number): number { + let low = 0; + let high = entries.length - 1; + let match = entries.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const entry = entries[mid]; + if (entry.top + entry.height >= offset) { + match = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.max(0, Math.min(match, entries.length)); +} + +function getEntryRangeForOffsets( + entries: LauncherVirtualEntry[], + startOffset: number, + endOffset: number +): { start: number; end: number } { + if (entries.length === 0) return { start: 0, end: 0 }; + const start = findEntryIndexAtOffset(entries, startOffset); + let end = start; + while (end < entries.length && entries[end].top <= endOffset) { + end += 1; + } + return { start, end }; +} + +function mergeEntryRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> { + const normalized = ranges + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + + normalized.forEach((range) => { + const last = merged[merged.length - 1]; + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end); + return; + } + merged.push({ ...range }); + }); + + return merged; +} + +function getVirtualizedEntries( + entries: LauncherVirtualEntry[], + scrollTop: number, + viewportHeight: number, + selectedIndex: number +): LauncherVirtualEntry[] { + const viewportStart = Math.max(0, scrollTop - VIRTUALIZATION_OVERSCAN_PX); + const viewportEnd = scrollTop + viewportHeight + VIRTUALIZATION_OVERSCAN_PX; + const ranges = [getEntryRangeForOffsets(entries, viewportStart, viewportEnd)]; + const selectedEntry = entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex); + + if (selectedEntry && (selectedEntry.top < viewportStart || selectedEntry.top + selectedEntry.height > viewportEnd)) { + ranges.push( + getEntryRangeForOffsets( + entries, + Math.max(0, selectedEntry.top - VIRTUALIZATION_OVERSCAN_PX), + selectedEntry.top + selectedEntry.height + VIRTUALIZATION_OVERSCAN_PX + ) + ); + } + + return mergeEntryRanges(ranges).flatMap((range) => entries.slice(range.start, range.end)); +} + +function useLatestValue(value: T): React.MutableRefObject { + const ref = React.useRef(value); + ref.current = value; + return ref; +} + type LauncherCommandListProps = { listRef: React.RefObject; itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; @@ -47,75 +217,180 @@ const LauncherCommandList: React.FC = ({ onCommandClick, onCommandContextMenu, t, -}) => ( -
- {isLoading ? ( -
-

{t('launcher.status.discoveringApps')}

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

{t('launcher.status.noMatchingResults')}

-
- ) : ( -
- {calcResult && ( - (itemRefs.current[0] = el)} - onCopy={onCalculatorCopy} - t={t} - /> - )} - - {sections.reduce( - (acc, section) => { - const startIndex = acc.index; - if (section.title) { - acc.nodes.push( -
- {section.title} -
- ); - } - section.items.forEach((command, i) => { - const flatIndex = startIndex + i; - const absoluteIndex = flatIndex + calcOffset; - const commandAlias = String(commandAliases[command.id] || '').trim(); - const commandHotkey = String(commandHotkeys[command.id] || '').trim(); - acc.nodes.push( - (itemRefs.current[absoluteIndex] = el)} - commandAlias={commandAlias} - commandHotkey={commandHotkey} - onClick={(event) => { - void onCommandClick(command, absoluteIndex, event); - }} - onContextMenu={(event) => onCommandContextMenu(event, command, absoluteIndex)} - t={t} - /> - ); - }); - acc.index += section.items.length; - return acc; - }, - { nodes: [] as React.ReactNode[], index: 0 } - ).nodes} +}) => { + const [scrollTop, setScrollTop] = React.useState(0); + const [viewportHeight, setViewportHeight] = React.useState(DEFAULT_VIEWPORT_HEIGHT); + const commandClickRef = useLatestValue(onCommandClick); + const commandContextMenuRef = useLatestValue(onCommandContextMenu); + + const virtualList = React.useMemo( + () => buildVirtualEntries(sections, calcResult, calcOffset), + [calcOffset, calcResult, sections] + ); + const shouldVirtualize = virtualList.entries.length > VIRTUALIZATION_THRESHOLD; + const visibleEntries = React.useMemo( + () => + shouldVirtualize + ? getVirtualizedEntries(virtualList.entries, scrollTop, viewportHeight, selectedIndex) + : virtualList.entries, + [scrollTop, selectedIndex, shouldVirtualize, viewportHeight, virtualList.entries] + ); + const selectedEntry = React.useMemo( + () => virtualList.entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex), + [selectedIndex, virtualList.entries] + ); + + const registerItemRef = React.useCallback( + (absoluteIndex: number, el: HTMLDivElement | null) => { + itemRefs.current[absoluteIndex] = el; + }, + [itemRefs] + ); + const registerCalculatorRef = React.useCallback( + (el: HTMLDivElement | null) => { + itemRefs.current[0] = el; + }, + [itemRefs] + ); + const handleCommandClick = React.useCallback( + (command: CommandInfo, absoluteIndex: number, event?: React.MouseEvent) => { + void commandClickRef.current(command, absoluteIndex, event); + }, + [commandClickRef] + ); + const handleCommandContextMenu = React.useCallback( + (event: React.MouseEvent, command: CommandInfo, absoluteIndex: number) => { + commandContextMenuRef.current(event, command, absoluteIndex); + }, + [commandContextMenuRef] + ); + const handleScroll = React.useCallback((event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop); + }, []); + + React.useEffect(() => { + const element = listRef.current; + if (!element || !shouldVirtualize) return; + + const updateViewportHeight = () => { + setViewportHeight(element.clientHeight || DEFAULT_VIEWPORT_HEIGHT); + setScrollTop(element.scrollTop); + }; + updateViewportHeight(); + + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateViewportHeight); + resizeObserver?.observe(element); + window.addEventListener('resize', updateViewportHeight); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateViewportHeight); + }; + }, [listRef, shouldVirtualize]); + + React.useEffect(() => { + const element = listRef.current; + if (!element || !shouldVirtualize || !selectedEntry) return; + + const currentTop = element.scrollTop; + const currentBottom = currentTop + (element.clientHeight || viewportHeight); + let nextTop: number | null = null; + + if (selectedEntry.top < currentTop) { + nextTop = selectedEntry.top; + } else if (selectedEntry.top + selectedEntry.height > currentBottom) { + nextTop = selectedEntry.top + selectedEntry.height - (element.clientHeight || viewportHeight); + } + + if (nextTop !== null) { + element.scrollTo({ top: Math.max(0, nextTop), behavior: 'smooth' }); + } + }, [listRef, selectedEntry, shouldVirtualize, viewportHeight]); + + const renderEntry = (entry: LauncherVirtualEntry, virtualized: boolean): React.ReactNode => { + let node: React.ReactNode; + if (entry.kind === 'calculator') { + node = ( + + ); + } else if (entry.kind === 'section') { + node = ( +
+ {entry.title} +
+ ); + } else { + const commandAlias = String(commandAliases[entry.command.id] || '').trim(); + const commandHotkey = String(commandHotkeys[entry.command.id] || '').trim(); + node = ( + + ); + } + + if (!virtualized) { + return {node}; + } + + return ( +
+ {node}
- )} -
-); + ); + }; + + return ( +
+ {isLoading ? ( +
+

{t('launcher.status.discoveringApps')}

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

{t('launcher.status.noMatchingResults')}

+
+ ) : shouldVirtualize ? ( +
+ {visibleEntries.map((entry) => renderEntry(entry, true))} +
+ ) : ( +
+ {visibleEntries.map((entry) => renderEntry(entry, false))} +
+ )} +
+ ); +}; export default LauncherCommandList; diff --git a/src/renderer/src/components/LauncherCommandRow.tsx b/src/renderer/src/components/LauncherCommandRow.tsx index 877b8247..62ada67a 100644 --- a/src/renderer/src/components/LauncherCommandRow.tsx +++ b/src/renderer/src/components/LauncherCommandRow.tsx @@ -12,30 +12,63 @@ import { type LauncherCommandRowProps = { command: CommandInfo; flatIndex: number; + absoluteIndex: number; selected: boolean; - itemRef: (el: HTMLDivElement | null) => void; + registerItemRef: (absoluteIndex: number, el: HTMLDivElement | null) => void; commandAlias: string; commandHotkey: string; - onClick: (event: React.MouseEvent) => void; - onContextMenu: (event: React.MouseEvent) => void; + onCommandClick: ( + command: CommandInfo, + selectedIndex: number, + event?: React.MouseEvent + ) => void | Promise; + onCommandContextMenu: ( + event: React.MouseEvent, + command: CommandInfo, + selectedIndex: number + ) => void; t: (key: string, params?: Record) => string; }; -const LauncherCommandRow: React.FC = ({ +const LauncherCommandRowComponent: React.FC = ({ command, flatIndex, + absoluteIndex, selected, - itemRef, + registerItemRef, commandAlias, commandHotkey, - onClick, - onContextMenu, + onCommandClick, + onCommandContextMenu, t, }) => { - const accessoryLabel = getCommandAccessoryLabel(command); - const typeBadgeLabel = getCommandTypeBadgeLabel(command, t); - const fallbackCategory = getCategoryLabel(command.category, t); - const hotkeyParts = commandHotkey ? getShortcutDisplayParts(commandHotkey) : []; + const accessoryLabel = React.useMemo(() => getCommandAccessoryLabel(command), [command]); + const typeBadgeLabel = React.useMemo(() => getCommandTypeBadgeLabel(command, t), [command, t]); + const fallbackCategory = React.useMemo(() => getCategoryLabel(command.category, t), [command.category, t]); + const hotkeyParts = React.useMemo( + () => (commandHotkey ? getShortcutDisplayParts(commandHotkey) : []), + [commandHotkey] + ); + const displayTitle = React.useMemo(() => getCommandDisplayTitle(command, t), [command, t]); + const commandIcon = React.useMemo(() => renderCommandIcon(command), [command]); + const itemRef = React.useCallback( + (el: HTMLDivElement | null) => { + registerItemRef(absoluteIndex, el); + }, + [absoluteIndex, registerItemRef] + ); + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + void onCommandClick(command, absoluteIndex, event); + }, + [absoluteIndex, command, onCommandClick] + ); + const handleContextMenu = React.useCallback( + (event: React.MouseEvent) => { + onCommandContextMenu(event, command, absoluteIndex); + }, + [absoluteIndex, command, onCommandContextMenu] + ); return (
= ({ className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ selected ? 'selected' : '' }`} - onClick={onClick} - onContextMenu={onContextMenu} + onClick={handleClick} + onContextMenu={handleContextMenu} >
- {renderCommandIcon(command)} + {commandIcon}
- {getCommandDisplayTitle(command, t)} + {displayTitle}
{accessoryLabel ? (
@@ -95,4 +128,6 @@ const LauncherCommandRow: React.FC = ({ ); }; +const LauncherCommandRow = React.memo(LauncherCommandRowComponent); + export default LauncherCommandRow; diff --git a/src/renderer/src/extension-fetch-bridge.ts b/src/renderer/src/extension-fetch-bridge.ts new file mode 100644 index 00000000..501dc66e --- /dev/null +++ b/src/renderer/src/extension-fetch-bridge.ts @@ -0,0 +1,193 @@ +interface ExtensionFetchElectronBridge { + httpRequest?: (options: { + url: string; + method?: string; + headers?: Record; + body?: string; + requestId?: string; + }) => Promise<{ + status: number; + statusText: string; + headers: Record; + bodyText: string; + url: string; + }>; + cancelHttpRequest?: (requestId: string) => void; + httpDownloadBinary?: (url: string, options?: { requestId?: string }) => Promise; +} + +let extensionFetchRequestSeq = 0; + +function createAbortError(): Error { + try { + return new DOMException('The operation was aborted.', 'AbortError'); + } catch { + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + return err; + } +} + +function createRequestId(g: any): string { + const randomId = + typeof g.crypto?.randomUUID === 'function' + ? g.crypto.randomUUID() + : Math.random().toString(36).slice(2); + return `extensionFetch:${Date.now()}:${++extensionFetchRequestSeq}:${randomId}`; +} + +function getRequestSignal(input: any, init?: any): AbortSignal | undefined { + if (init?.signal) return init.signal; + if (typeof Request !== 'undefined' && input instanceof Request) return input.signal; + return input?.signal; +} + +export function installExtensionFetchBridge( + g: any, + getElectronBridge: () => ExtensionFetchElectronBridge | undefined = () => (globalThis as any).window?.electron +): void { + if (!g.__SUPERCMD_NATIVE_FETCH && typeof g.fetch === 'function') { + g.__SUPERCMD_NATIVE_FETCH = g.fetch.bind(g); + } + if (g.__SUPERCMD_FETCH_PATCHED) return; + + const nativeFetch = g.__SUPERCMD_NATIVE_FETCH; + const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); + const toHeadersObject = (headersLike: any): Record => { + const out: Record = {}; + if (!headersLike) return out; + try { + const normalized = new Headers(headersLike as HeadersInit); + normalized.forEach((v, k) => { + out[k] = v; + }); + } catch { + if (typeof headersLike === 'object') { + for (const [k, v] of Object.entries(headersLike)) { + out[k] = String(v); + } + } + } + return out; + }; + const normalizeBody = async (body: any): Promise => { + if (body == null) return undefined; + if (typeof body === 'string') return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return await body.text(); + if (typeof body === 'object') return JSON.stringify(body); + return String(body); + }; + + g.fetch = async (input: any, init?: any) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input?.url || String(input ?? ''); + + const electronBridge = getElectronBridge(); + + // Only proxy HTTP(S) requests. + if (!isHttpUrl(url) || !electronBridge?.httpRequest) { + return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); + } + + // FormData/streams are not representable via current IPC payload. Fall back. + const requestBody = init?.body; + if ( + requestBody instanceof FormData || + requestBody instanceof ReadableStream || + (typeof requestBody === 'object' && requestBody?.getReader) + ) { + return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); + } + + const method = (init?.method || input?.method || 'GET').toUpperCase(); + const headers = { + ...toHeadersObject(input?.headers), + ...toHeadersObject(init?.headers), + }; + const body = await normalizeBody(requestBody); + const signal = getRequestSignal(input, init); + const requestId = createRequestId(g); + let cancelSent = false; + let abortListener: (() => void) | undefined; + const sendCancel = () => { + if (cancelSent) return; + cancelSent = true; + electronBridge.cancelHttpRequest?.(requestId); + }; + const removeAbortListener = () => { + if (!signal || !abortListener) return; + signal.removeEventListener('abort', abortListener); + abortListener = undefined; + }; + + if (signal?.aborted) throw createAbortError(); + if (signal) { + abortListener = sendCancel; + signal.addEventListener('abort', abortListener, { once: true }); + } + + const binaryDownloader = electronBridge.httpDownloadBinary; + const canDownloadBinary = method === 'GET' && typeof binaryDownloader === 'function'; + let ipcRes: Awaited>>; + try { + ipcRes = await electronBridge.httpRequest({ url, method, headers, body, requestId }); + if (signal?.aborted) throw createAbortError(); + + if (!ipcRes || ipcRes.status === 0) { + if (typeof nativeFetch === 'function') { + try { + return await nativeFetch(input, init); + } catch (nativeErr: any) { + const proxyMsg = ipcRes?.statusText || `Failed to fetch ${url}`; + const nativeMsg = nativeErr?.message || String(nativeErr); + throw new TypeError(`${proxyMsg}; native fetch fallback failed: ${nativeMsg}`); + } + } + throw new TypeError(ipcRes?.statusText || `Failed to fetch ${url}`); + } + + const contentType = String( + ipcRes.headers?.['content-type'] || + ipcRes.headers?.['Content-Type'] || + '' + ).toLowerCase(); + const requestAccept = String(headers?.Accept || headers?.accept || '').toLowerCase(); + const looksLikeBinaryUrl = /\.(gif|png|apng|jpe?g|webp|bmp|ico|icns|tiff?|mp3|wav|ogg|aac|m4a|mp4|mov|webm|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|bz2|7z|rar)(?:[?#]|$)/i.test(url); + const isBinaryContentType = + /^image\/(?!svg\+xml)/i.test(contentType) || + /^(audio|video|font)\//i.test(contentType) || + /^application\/(?:octet-stream|pdf|zip|gzip|x-gzip|x-bzip|x-7z-compressed|x-rar-compressed)/i.test(contentType); + const prefersBinaryResponse = requestAccept.includes('image/') || requestAccept.includes('application/octet-stream'); + + let rawBytes: Uint8Array | null = null; + if (canDownloadBinary && (isBinaryContentType || prefersBinaryResponse || looksLikeBinaryUrl)) { + if (signal?.aborted) throw createAbortError(); + rawBytes = await binaryDownloader(url, { requestId }).catch(() => null as Uint8Array | null); + if (signal?.aborted) throw createAbortError(); + } + + // Build Response with binary body when available, text otherwise. + const responseBody = rawBytes && rawBytes.length > 0 ? rawBytes as BodyInit : (ipcRes.bodyText ?? ''); + const response = new Response(responseBody, { + status: ipcRes.status, + statusText: ipcRes.statusText || '', + headers: ipcRes.headers || {}, + }); + + try { + Object.defineProperty(response, 'url', { value: ipcRes.url || url }); + } catch {} + + return response; + } finally { + removeAbortListener(); + } + }; + + g.__SUPERCMD_FETCH_PATCHED = true; +} diff --git a/src/renderer/src/hooks/backgroundRefreshTimers.ts b/src/renderer/src/hooks/backgroundRefreshTimers.ts new file mode 100644 index 00000000..e067fc35 --- /dev/null +++ b/src/renderer/src/hooks/backgroundRefreshTimers.ts @@ -0,0 +1,136 @@ +import type { CommandInfo } from '../../types/electron'; + +export type BackgroundRefreshTimerKind = 'extension' | 'inline-script'; + +export interface BackgroundRefreshTimerDescriptor { + key: string; + kind: BackgroundRefreshTimerKind; + command: CommandInfo; + intervalMs: number; + extensionCommand?: { extName: string; cmdName: string }; +} + +export interface BackgroundRefreshTimerEntry { + timerId: TTimerId; +} + +export interface BackgroundRefreshTimerReconcileResult { + created: number; + retained: number; + cleared: number; +} + +export interface BackgroundRefreshTimerReconcileOptions { + timers: Map>; + descriptors: BackgroundRefreshTimerDescriptor[]; + createTimer: (descriptor: BackgroundRefreshTimerDescriptor) => TTimerId; + clearTimer: (timerId: TTimerId) => void; +} + +export function parseExtensionCommandPath(pathValue: string): { extName: string; cmdName: string } | null { + const rawPath = String(pathValue || '').trim(); + const separatorIndex = rawPath.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex >= rawPath.length - 1) return null; + + const extName = rawPath.slice(0, separatorIndex).trim(); + const cmdName = rawPath.slice(separatorIndex + 1).trim(); + if (!extName || !cmdName) return null; + + return { extName, cmdName }; +} + +export function getBackgroundRefreshTimerKey(cmd: CommandInfo): string | null { + if (cmd.category === 'extension' && typeof cmd.interval === 'string' && cmd.path) { + const identity = parseExtensionCommandPath(cmd.path); + if (!identity) return null; + + return [ + 'extension', + cmd.id, + `${identity.extName}/${identity.cmdName}`, + cmd.path.trim(), + cmd.mode || '', + cmd.interval, + ].join('\u0000'); + } + + if (cmd.category === 'script' && cmd.mode === 'inline' && typeof cmd.interval === 'string') { + return ['inline-script', cmd.id, (cmd.path || '').trim(), cmd.mode, cmd.interval].join('\u0000'); + } + + return null; +} + +export function getBackgroundRefreshTimerDescriptors( + commands: CommandInfo[], + parseIntervalToMs: (interval: string) => number | null +): BackgroundRefreshTimerDescriptor[] { + const descriptors: BackgroundRefreshTimerDescriptor[] = []; + const seenKeys = new Set(); + + for (const cmd of commands) { + const key = getBackgroundRefreshTimerKey(cmd); + if (!key || seenKeys.has(key) || typeof cmd.interval !== 'string') continue; + + const intervalMs = parseIntervalToMs(cmd.interval); + if (!intervalMs) continue; + + descriptors.push({ + key, + kind: cmd.category === 'extension' ? 'extension' : 'inline-script', + command: cmd, + intervalMs, + extensionCommand: cmd.category === 'extension' ? parseExtensionCommandPath(cmd.path || '') || undefined : undefined, + }); + seenKeys.add(key); + } + + return descriptors; +} + +export function reconcileBackgroundRefreshTimers({ + timers, + descriptors, + createTimer, + clearTimer, +}: BackgroundRefreshTimerReconcileOptions): BackgroundRefreshTimerReconcileResult { + const nextKeys = new Set(descriptors.map((descriptor) => descriptor.key)); + let created = 0; + let retained = 0; + let cleared = 0; + + for (const [key, entry] of Array.from(timers.entries())) { + if (!nextKeys.has(key)) { + clearTimer(entry.timerId); + timers.delete(key); + cleared += 1; + } + } + + for (const descriptor of descriptors) { + if (timers.has(descriptor.key)) { + retained += 1; + continue; + } + + timers.set(descriptor.key, { timerId: createTimer(descriptor) }); + created += 1; + } + + return { created, retained, cleared }; +} + +export function clearBackgroundRefreshTimers( + timers: Map>, + clearTimer: (timerId: TTimerId) => void +): number { + let cleared = 0; + + for (const entry of timers.values()) { + clearTimer(entry.timerId); + cleared += 1; + } + + timers.clear(); + return cleared; +} diff --git a/src/renderer/src/hooks/cursorPromptResultBatcher.ts b/src/renderer/src/hooks/cursorPromptResultBatcher.ts new file mode 100644 index 00000000..0cf68fe3 --- /dev/null +++ b/src/renderer/src/hooks/cursorPromptResultBatcher.ts @@ -0,0 +1,106 @@ +export const CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS = 32; + +export interface CursorPromptResultRef { + current: string; +} + +export interface CursorPromptResultBatcherOptions { + resultRef: CursorPromptResultRef; + setVisibleResult: (value: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface CursorPromptResultBatcher { + appendChunk: (chunk: string) => void; + flush: () => void; + reset: (nextResult?: string) => void; + cancelPendingFlush: () => void; + dispose: () => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createCursorPromptResultBatcher({ + resultRef, + setVisibleResult, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS, +}: CursorPromptResultBatcherOptions): CursorPromptResultBatcher { + let visibleResult = resultRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleResult = () => { + const nextResult = resultRef.current; + if (nextResult === visibleResult) return; + visibleResult = nextResult; + setVisibleResult(nextResult); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleResult(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + resultRef.current += chunk; + scheduleFlush(); + }, + flush() { + cancelPendingFlush(); + publishVisibleResult(); + }, + reset(nextResult = '') { + cancelPendingFlush(); + resultRef.current = nextResult; + visibleResult = nextResult; + setVisibleResult(nextResult); + }, + cancelPendingFlush, + dispose: cancelPendingFlush, + }; +} diff --git a/src/renderer/src/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..a4bf7b7f 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -20,6 +20,7 @@ import type { AiChatMessage as AiMessage, AiChatSnapshot, } from '../../types/electron'; +import { createAiChatStreamBuffer } from '../utils/ai-chat-stream-buffer'; export type { AiConversation, AiMessage }; @@ -36,7 +37,7 @@ export interface UseAiChatReturn { setAiQuery: (value: string) => void; aiInputRef: React.RefObject; aiResponseRef: React.RefObject; - setAiAvailable: (value: boolean) => void; + setAiAvailable: React.Dispatch>; conversations: AiConversation[]; activeConversationId: string | null; startAiChat: (searchQuery: string) => void; @@ -80,9 +81,65 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const streamingMessageIdRef = useRef(null); const activeConversationIdRef = useRef(null); const messagesRef = useRef([]); + const streamBufferRef = useRef | null>(null); const aiInputRef = useRef(null); const aiResponseRef = useRef(null); + const setMessagesSnapshot = useCallback((nextMessages: AiMessage[]) => { + messagesRef.current = nextMessages; + setMessages(nextMessages); + }, []); + + const updateMessagesSnapshot = useCallback((updater: (current: AiMessage[]) => AiMessage[]) => { + const current = messagesRef.current; + const next = updater(current); + if (next === current) return current; + messagesRef.current = next; + setMessages(next); + return next; + }, []); + + const applyStreamingContent = useCallback( + (messageId: string, content: string) => { + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.content === content) return message; + changed = true; + return { ...message, content }; + }); + return changed ? next : current; + }); + }, + [updateMessagesSnapshot] + ); + + if (streamBufferRef.current === null) { + streamBufferRef.current = createAiChatStreamBuffer({ + onFlush: (content) => { + const messageId = streamingMessageIdRef.current; + if (messageId) { + applyStreamingContent(messageId, content); + } + }, + }); + } + + const flushStreamingBuffer = useCallback(() => { + streamBufferRef.current?.flushNow(); + }, []); + + const resetStreamingBuffer = useCallback((content = '') => { + streamBufferRef.current?.reset(content); + }, []); + + useEffect(() => { + return () => { + streamBufferRef.current?.cancel(); + }; + }, []); + useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); @@ -104,7 +161,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const nextActive = nextConversations.find((conversation) => conversation.id === activeId); if (nextActive) { - setMessages(nextActive.messages); + setMessagesSnapshot(nextActive.messages); return; } @@ -112,7 +169,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC activeConversationIdRef.current = null; setActiveConversationId(null); } - }, []); + }, [setMessagesSnapshot]); const refreshSnapshot = useCallback(() => { void window.electron.getAiChatSnapshot() @@ -146,37 +203,29 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const appendToStreamingMessage = (chunk: string) => { const msgId = streamingMessageIdRef.current; if (!msgId) return; - setMessages((prev) => - prev.map((message) => ( - message.id === msgId - ? { ...message, content: message.content + chunk } - : message - )) - ); + streamBufferRef.current?.append(chunk); }; const finalizeConversation = () => { const conversationId = activeConversationIdRef.current; if (!conversationId) return; - setMessages((current) => { - const existing = conversations.find((conversation) => conversation.id === conversationId); - const updatedConversation: AiConversation = { - id: conversationId, - title: - existing?.title && existing.title !== 'New Chat' - ? existing.title - : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), - messages: current, - createdAt: existing?.createdAt ?? Date.now(), - updatedAt: Date.now(), - source: existing?.source || 'local', - ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), - ...(existing?.metadata ? { metadata: existing.metadata } : {}), - }; - persistConversation(updatedConversation); - return current; - }); + const current = messagesRef.current; + const existing = conversations.find((conversation) => conversation.id === conversationId); + const updatedConversation: AiConversation = { + id: conversationId, + title: + existing?.title && existing.title !== 'New Chat' + ? existing.title + : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), + messages: current, + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + source: existing?.source || 'local', + ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), + ...(existing?.metadata ? { metadata: existing.metadata } : {}), + }; + persistConversation(updatedConversation); }; const handleChunk = (data: { requestId: string; chunk: string }) => { @@ -187,10 +236,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const handleDone = (data: { requestId: string }) => { if (data.requestId === aiRequestIdRef.current) { + flushStreamingBuffer(); aiStreamingRef.current = false; setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -199,20 +250,14 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiStreamingRef.current = false; const msgId = streamingMessageIdRef.current; if (msgId) { - setMessages((prev) => - prev.map((message) => - message.id === msgId - ? { - ...message, - content: message.content + (message.content ? '\n\n' : '') + `Error: ${data.error}`, - } - : message - ) - ); + const currentContent = streamBufferRef.current?.getContent() ?? ''; + streamBufferRef.current?.append(`${currentContent ? '\n\n' : ''}Error: ${data.error}`); + flushStreamingBuffer(); } setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -227,7 +272,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC removeDone?.(); removeError?.(); }; - }, [hasBeenActivated, conversations, persistConversation]); + }, [hasBeenActivated, conversations, flushStreamingBuffer, persistConversation, resetStreamingBuffer]); useEffect(() => { if (aiResponseRef.current) { @@ -283,16 +328,16 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC content: '', createdAt: Date.now(), }; + resetStreamingBuffer(); streamingMessageIdRef.current = assistantMessage.id; - setMessages((prev) => { - const next = [...prev, userMessage, assistantMessage]; - sendChatTurn([...prev, userMessage]); - return next; - }); + const currentMessages = messagesRef.current; + const nextMessages = [...currentMessages, userMessage, assistantMessage]; + setMessagesSnapshot(nextMessages); + sendChatTurn([...currentMessages, userMessage]); setAiQuery(''); }, - [aiAvailable, persistConversation, sendChatTurn] + [aiAvailable, persistConversation, resetStreamingBuffer, sendChatTurn, setMessagesSnapshot] ); const startAiChat = useCallback( @@ -301,7 +346,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setHasBeenActivated(true); activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiMode(true); const trimmed = searchQuery.trim(); if (trimmed) { @@ -310,10 +356,11 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiQuery(''); } }, - [aiAvailable, setAiMode, sendMessage] + [aiAvailable, resetStreamingBuffer, setAiMode, sendMessage, setMessagesSnapshot] ); const stopStreaming = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -321,13 +368,21 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiStreaming(false); const messageId = streamingMessageIdRef.current; if (messageId) { - setMessages((prev) => - prev.map((message) => (message.id === messageId ? { ...message, cancelled: true } : message)) - ); + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.cancelled) return message; + changed = true; + return { ...message, cancelled: true }; + }); + return changed ? next : current; + }); } streamingMessageIdRef.current = null; aiRequestIdRef.current = null; - }, []); + resetStreamingBuffer(); + }, [flushStreamingBuffer, resetStreamingBuffer, updateMessagesSnapshot]); const newChat = useCallback(() => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -338,11 +393,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiStreaming(false); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const selectConversation = useCallback((id: string) => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -351,6 +407,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiRequestIdRef.current = null; aiStreamingRef.current = false; streamingMessageIdRef.current = null; + resetStreamingBuffer(); setAiStreaming(false); setConversations((current) => { @@ -358,13 +415,13 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (conversation) { activeConversationIdRef.current = id; setActiveConversationId(id); - setMessages(conversation.messages); + setMessagesSnapshot(conversation.messages); } return current; }); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const deleteConversation = useCallback((id: string) => { setConversations((prev) => prev.filter((conversation) => conversation.id !== id)); @@ -372,7 +429,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (activeConversationIdRef.current === id) { activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -381,9 +439,10 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; setAiStreaming(false); } - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const exitAiMode = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -393,8 +452,9 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiMode(false); setAiStreaming(false); setAiQuery(''); + resetStreamingBuffer(); onExitAiMode?.(); - }, [setAiMode, onExitAiMode]); + }, [flushStreamingBuffer, resetStreamingBuffer, setAiMode, onExitAiMode]); useEffect(() => { if (messages.length === 0 && !aiQuery && !aiStreaming) return; diff --git a/src/renderer/src/hooks/useBackgroundRefresh.ts b/src/renderer/src/hooks/useBackgroundRefresh.ts index 0c19005d..013a2903 100644 --- a/src/renderer/src/hooks/useBackgroundRefresh.ts +++ b/src/renderer/src/hooks/useBackgroundRefresh.ts @@ -7,13 +7,21 @@ * - Inline script commands (category: "script", mode: "inline"): runs the script and * calls fetchCommands() to refresh the subtitle shown in the launcher * - * All timers are cleared and re-registered whenever the `commands` list changes. - * This ensures newly installed or removed commands are handled correctly. + * Timers are keyed by stable command identity, path, mode, and interval so + * unchanged background commands keep their existing intervals across command + * list refreshes. Removed commands are cleared, and changed commands restart. */ -import { useRef, useEffect } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; import type { CommandInfo } from '../../types/electron'; import { parseIntervalToMs } from '../utils/command-helpers'; +import { + clearBackgroundRefreshTimers, + getBackgroundRefreshTimerDescriptors, + reconcileBackgroundRefreshTimers, + type BackgroundRefreshTimerEntry, + type BackgroundRefreshTimerDescriptor, +} from './backgroundRefreshTimers'; import { readJsonObject, getScriptCmdArgsKey, @@ -36,41 +44,20 @@ export interface UseBackgroundRefreshOptions { } export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommandActive }: UseBackgroundRefreshOptions): void { - const intervalTimerIdsRef = useRef([]); + const intervalTimersRef = useRef>>(new Map()); + const fetchCommandsRef = useRef(fetchCommands); + fetchCommandsRef.current = fetchCommands; + const isMenuBarCommandActiveRef = useRef(isMenuBarCommandActive); isMenuBarCommandActiveRef.current = isMenuBarCommandActive; - const parseExtensionCommandPath = (pathValue: string): { extName: string; cmdName: string } | null => { - const rawPath = String(pathValue || '').trim(); - const separatorIndex = rawPath.indexOf('/'); - if (separatorIndex <= 0 || separatorIndex >= rawPath.length - 1) return null; - - const extName = rawPath.slice(0, separatorIndex).trim(); - const cmdName = rawPath.slice(separatorIndex + 1).trim(); - if (!extName || !cmdName) return null; - - return { extName, cmdName }; - }; - - useEffect(() => { - for (const timerId of intervalTimerIdsRef.current) { - window.clearInterval(timerId); - } - intervalTimerIdsRef.current = []; - - const extensionCommands = commands.filter( - (cmd) => cmd.category === 'extension' && typeof cmd.interval === 'string' && cmd.path - ); + const createTimer = useCallback((descriptor: BackgroundRefreshTimerDescriptor): number => { + const cmd = descriptor.command; - for (const cmd of extensionCommands) { - const ms = parseIntervalToMs(cmd.interval); - if (!ms) continue; + if (descriptor.kind === 'extension') { + const { extName, cmdName } = descriptor.extensionCommand!; - const identity = parseExtensionCommandPath(cmd.path || ''); - if (!identity) continue; - const { extName, cmdName } = identity; - - const timerId = window.setInterval(async () => { + return window.setInterval(async () => { try { const result = await window.electron.runExtension(extName, cmdName); if (!result || !result.code) return; @@ -110,47 +97,41 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand } catch (error) { console.error('[BackgroundRefresh] Failed to run command:', cmd.id, error); } - }, ms); - - intervalTimerIdsRef.current.push(timerId); + }, descriptor.intervalMs); } - const inlineScriptCommands = commands.filter( - (cmd) => - cmd.category === 'script' && - cmd.mode === 'inline' && - typeof cmd.interval === 'string' - ); - for (const cmd of inlineScriptCommands) { - const ms = parseIntervalToMs(cmd.interval); - if (!ms) continue; - - const timerId = window.setInterval(async () => { - try { - const storedArgs = readJsonObject(getScriptCmdArgsKey(cmd.id)); - const missingArgs = getMissingRequiredScriptArguments(cmd, storedArgs); - if (missingArgs.length > 0) return; - const result = await window.electron.runScriptCommand({ - commandId: cmd.id, - arguments: storedArgs, - background: true, - }); - if (result?.mode === 'inline') { - await fetchCommands(); - } - } catch (error) { - console.error('[BackgroundRefresh] Failed to run script command:', cmd.id, error); + return window.setInterval(async () => { + try { + const storedArgs = readJsonObject(getScriptCmdArgsKey(cmd.id)); + const missingArgs = getMissingRequiredScriptArguments(cmd, storedArgs); + if (missingArgs.length > 0) return; + const result = await window.electron.runScriptCommand({ + commandId: cmd.id, + arguments: storedArgs, + background: true, + }); + if (result?.mode === 'inline') { + await fetchCommandsRef.current(); } - }, ms); + } catch (error) { + console.error('[BackgroundRefresh] Failed to run script command:', cmd.id, error); + } + }, descriptor.intervalMs); + }, []); - intervalTimerIdsRef.current.push(timerId); - } + useEffect(() => { + const descriptors = getBackgroundRefreshTimerDescriptors(commands, parseIntervalToMs); + reconcileBackgroundRefreshTimers({ + timers: intervalTimersRef.current, + descriptors, + createTimer, + clearTimer: (timerId) => window.clearInterval(timerId), + }); + }, [commands, createTimer]); + useEffect(() => { return () => { - for (const timerId of intervalTimerIdsRef.current) { - window.clearInterval(timerId); - } - intervalTimerIdsRef.current = []; + clearBackgroundRefreshTimers(intervalTimersRef.current, (timerId) => window.clearInterval(timerId)); }; - }, [commands, fetchCommands]); + }, []); } diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index e7f3bf2a..aeb9adf7 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -354,6 +354,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult const getBookmarkResults = useCallback((rawInput: string, limit = MAX_SCOPED_BOOKMARK_RESULTS): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('bookmark', decorateBrowserResults(getBrowserEntryCandidates('bookmark', rawInput, entriesRef.current, { + entryIndex: index, preserveBookmarkOrder: !rawInput.trim(), limit, nicknames: nicknamesRef.current, @@ -369,6 +370,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult ): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('history', decorateBrowserResults(getBrowserEntryCandidates('history', rawInput, entriesRef.current, { + entryIndex: index, preserveHistoryChronology: true, includeHistoryTimestamp: true, showHistoryProfileContext: showProfileContext, @@ -667,41 +669,73 @@ type BrowserEntrySearchIndex = { searchFields: TokenSearchField[]; }; +type BrowserEntryKindIndex = { + tokenPrefixEntryIds: Map; + tokenTrigramEntryIds: Map; +}; + type BrowserEntryIndex = { historyByTimeEntryIds: number[]; bookmarksByBrowserOrderEntryIds: number[]; + entrySearchIndexes: Array; + entriesByKind: { + history: BrowserEntryKindIndex; + bookmark: BrowserEntryKindIndex; + }; + bookmarkEntryIdsByNicknameKey: Map; profileCountsByKind: { history: Map; bookmark: Map; }; }; +const EMPTY_BROWSER_ENTRY_KIND_INDEX: BrowserEntryKindIndex = { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), +}; + const EMPTY_BROWSER_ENTRY_INDEX: BrowserEntryIndex = { historyByTimeEntryIds: [], bookmarksByBrowserOrderEntryIds: [], + entrySearchIndexes: [], + entriesByKind: { + history: EMPTY_BROWSER_ENTRY_KIND_INDEX, + bookmark: EMPTY_BROWSER_ENTRY_KIND_INDEX, + }, + bookmarkEntryIdsByNicknameKey: new Map(), profileCountsByKind: { history: new Map(), bookmark: new Map() }, }; const BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128; const BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096; +const BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH = 2; const BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX = 2_000; const browserEntrySearchIndexCache = new Map(); function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryIndex { const historyByTimeEntryIds: number[] = []; const bookmarksByBrowserOrderEntryIds: number[] = []; + const entrySearchIndexes: Array = new Array(entries.length); + const historyIndex = createBrowserEntryKindIndex(); + const bookmarkIndex = createBrowserEntryKindIndex(); + const bookmarkEntryIdsByNicknameKey = new Map(); const historyProfileCounts = new Map(); const bookmarkProfileCounts = new Map(); entries.forEach((entry, entryId) => { if (entry.type !== 'url' && entry.type !== 'bookmark') return; + const searchIndex = createBrowserEntrySearchIndex(entry); + entrySearchIndexes[entryId] = searchIndex; if (entry.type === 'url') { historyByTimeEntryIds.push(entryId); + addBrowserEntryToKindIndex(historyIndex, entryId, searchIndex); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); historyProfileCounts.set(key, (historyProfileCounts.get(key) || 0) + 1); } } else { bookmarksByBrowserOrderEntryIds.push(entryId); + addBrowserEntryToKindIndex(bookmarkIndex, entryId, searchIndex); + addBookmarkEntryToNicknameIndex(bookmarkEntryIdsByNicknameKey, entryId, entry); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); bookmarkProfileCounts.set(key, (bookmarkProfileCounts.get(key) || 0) + 1); @@ -713,6 +747,12 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde return { historyByTimeEntryIds, bookmarksByBrowserOrderEntryIds, + entrySearchIndexes, + entriesByKind: { + history: historyIndex, + bookmark: bookmarkIndex, + }, + bookmarkEntryIdsByNicknameKey, profileCountsByKind: { history: historyProfileCounts, bookmark: bookmarkProfileCounts, @@ -720,6 +760,77 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde }; } +function createBrowserEntryKindIndex(): BrowserEntryKindIndex { + return { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), + }; +} + +function addBrowserEntryToKindIndex( + kindIndex: BrowserEntryKindIndex, + entryId: number, + searchIndex: BrowserEntrySearchIndex +): void { + const tokens = getEntryIndexTokens(searchIndex); + const prefixes = new Set(); + const trigrams = new Set(); + for (const token of tokens) { + prefixes.add(token.slice(0, BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH)); + if (token.length >= 3) { + for (let index = 0; index <= token.length - 3; index += 1) { + trigrams.add(token.slice(index, index + 3)); + } + } + } + for (const prefix of prefixes) pushEntryId(kindIndex.tokenPrefixEntryIds, prefix, entryId); + for (const trigram of trigrams) pushEntryId(kindIndex.tokenTrigramEntryIds, trigram, entryId); +} + +function getEntryIndexTokens(searchIndex: BrowserEntrySearchIndex): string[] { + const seen = new Set(); + const tokens: string[] = []; + for (const field of searchIndex.searchFields) { + const value = field.value || ''; + if (!value) continue; + for (const token of value.split(' ')) { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH || seen.has(token)) continue; + seen.add(token); + tokens.push(token); + } + } + return tokens; +} + +function pushEntryId(index: Map, key: string, entryId: number): void { + const existing = index.get(key); + if (existing) { + existing.push(entryId); + return; + } + index.set(key, [entryId]); +} + +function addBookmarkEntryToNicknameIndex( + index: Map, + entryId: number, + entry: BrowserSearchEntry +): void { + const url = normalizeNicknameUrl(entry.url); + if (!url) return; + const source = String(entry.source || ''); + const profileId = String(entry.sourceProfileId || ''); + const fullProfileId = getEntryProfileKey(entry); + pushEntryId(index, getBookmarkNicknameLookupKey(source, profileId, url), entryId); + if (fullProfileId !== profileId) { + pushEntryId(index, getBookmarkNicknameLookupKey(source, fullProfileId, url), entryId); + } +} + +function getBookmarkNicknameLookupKey(source: string, sourceProfileId: string, url: string): string { + return [source, sourceProfileId, url].join('\0'); +} + function compareHistoryEntriesByTime(a: BrowserSearchEntry, b: BrowserSearchEntry): number { const aTime = Number.isFinite(Number(a?.lastUsedAt)) ? Number(a.lastUsedAt) : 0; const bTime = Number.isFinite(Number(b?.lastUsedAt)) ? Number(b.lastUsedAt) : 0; @@ -868,14 +979,15 @@ function buildBrowserCandidates( options: { limitPerKind?: number } = {} ): Record { const openTabs = getOpenTabCandidates(input, tabs); - void entryIndex; return { 'open-tab': openTabs, bookmark: getBrowserEntryCandidates('bookmark', input, entries, { + entryIndex, nicknames, limit: options.limitPerKind, }), history: getBrowserEntryCandidates('history', input, entries, { + entryIndex, limit: options.limitPerKind, }), }; @@ -886,6 +998,7 @@ function getBrowserEntryCandidates( input: string, entries: BrowserSearchEntry[], options: { + entryIndex?: BrowserEntryIndex | null; preserveBookmarkOrder?: boolean; preserveHistoryChronology?: boolean; includeHistoryTimestamp?: boolean; @@ -912,7 +1025,6 @@ function getBrowserEntryCandidates( if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -948,11 +1060,22 @@ function getBrowserEntryCandidates( } return results; } - const candidateEntries = entries; - for (const entry of candidateEntries) { + const indexedEntryIds = getIndexedEntryCandidateIds(kind, queryTokens, options.entryIndex || null); + const nicknameEntryIds = indexedEntryIds !== null + ? getBookmarkNicknameCandidateIds(kind, trimmed, options.nicknames || [], options.entryIndex || null) + : null; + const candidateEntryIds = indexedEntryIds && nicknameEntryIds + ? unionSortedEntryIds(indexedEntryIds, nicknameEntryIds) + : indexedEntryIds; + const scanEntryIds = candidateEntryIds || null; + const scanLength = scanEntryIds ? scanEntryIds.length : entries.length; + for (let scanIndex = 0; scanIndex < scanLength; scanIndex += 1) { + const entryId = scanEntryIds ? scanEntryIds[scanIndex] : scanIndex; + const entry = entries[entryId]; + if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); + const index = getBrowserEntrySearchIndexForEntry(entry, entryId, options.entryIndex || null); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -1035,6 +1158,155 @@ function getBrowserEntryCandidates( return options.limit && options.limit > 0 ? sorted.slice(0, options.limit) : sorted; } +function getIndexedEntryCandidateIds( + kind: 'bookmark' | 'history', + queryTokens: string[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (!entryIndex || queryTokens.length === 0) return null; + const kindIndex = entryIndex.entriesByKind[kind]; + if (!kindIndex) return null; + + const tokenLists: number[][] = []; + for (const token of queryTokens) { + const tokenEntryIds = getIndexedEntryIdsForToken(kindIndex, token); + if (!tokenEntryIds) return null; + if (tokenEntryIds.length === 0) return []; + tokenLists.push(tokenEntryIds); + } + + tokenLists.sort((a, b) => a.length - b.length); + let candidateIds = tokenLists[0] || []; + for (let index = 1; index < tokenLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, tokenLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getBookmarkNicknameCandidateIds( + kind: 'bookmark' | 'history', + input: string, + nicknames: BrowserSearchNicknameSetting[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (kind !== 'bookmark' || !entryIndex || nicknames.length === 0 || /\s/.test(input)) return null; + const parsed = parseNicknameQuery(input); + const normalizedToken = normalizeNicknameToken(parsed.firstToken); + if (!normalizedToken) return null; + let candidateIds: number[] = []; + for (const item of nicknames) { + const nickname = normalizeNicknameToken(item.nickname); + if (!nickname || !nickname.startsWith(normalizedToken)) continue; + const key = getBookmarkNicknameLookupKey( + String(item.source || ''), + String(item.sourceProfileId || ''), + normalizeNicknameUrl(item.url) + ); + const entryIds = entryIndex.bookmarkEntryIdsByNicknameKey.get(key); + if (entryIds?.length) candidateIds = unionSortedEntryIds(candidateIds, entryIds); + } + return candidateIds; +} + +function getIndexedEntryIdsForToken( + kindIndex: BrowserEntryKindIndex, + token: string +): number[] | null { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) return null; + if (token.length === BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) { + return kindIndex.tokenPrefixEntryIds.get(token) || []; + } + const trigrams = getTokenTrigrams(token); + if (trigrams.length === 0) return null; + const gramLists: number[][] = []; + for (const trigram of trigrams) { + const ids = kindIndex.tokenTrigramEntryIds.get(trigram) || []; + if (ids.length === 0) return []; + gramLists.push(ids); + } + gramLists.sort((a, b) => a.length - b.length); + let candidateIds = gramLists[0] || []; + for (let index = 1; index < gramLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, gramLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getTokenTrigrams(token: string): string[] { + if (token.length < 3) return []; + const seen = new Set(); + const trigrams: string[] = []; + for (let index = 0; index <= token.length - 3; index += 1) { + const trigram = token.slice(index, index + 3); + if (seen.has(trigram)) continue; + seen.add(trigram); + trigrams.push(trigram); + } + return trigrams; +} + +function intersectSortedEntryIds(a: number[], b: number[]): number[] { + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + aIndex += 1; + } else { + bIndex += 1; + } + } + return out; +} + +function unionSortedEntryIds(a: number[], b: number[]): number[] { + if (a.length === 0) return b; + if (b.length === 0) return a; + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + out.push(aValue); + aIndex += 1; + } else { + out.push(bValue); + bIndex += 1; + } + } + while (aIndex < a.length) { + out.push(a[aIndex]); + aIndex += 1; + } + while (bIndex < b.length) { + out.push(b[bIndex]); + bIndex += 1; + } + return out; +} + +function getBrowserEntrySearchIndexForEntry( + entry: BrowserSearchEntry, + entryId: number, + entryIndex: BrowserEntryIndex | null +): BrowserEntrySearchIndex { + return entryIndex?.entrySearchIndexes[entryId] || getBrowserEntrySearchIndex(entry); +} + function compareBookmarksByBrowserOrder(a: BrowserSearchResult, b: BrowserSearchResult): number { const aOrder = Number.isFinite(Number(a.bookmarkOrder)) ? Number(a.bookmarkOrder) : Number.MAX_SAFE_INTEGER; const bOrder = Number.isFinite(Number(b.bookmarkOrder)) ? Number(b.bookmarkOrder) : Number.MAX_SAFE_INTEGER; @@ -1338,6 +1610,16 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear browserEntrySearchIndexCache.set(cacheKey, cached); return cached.index; } + const index = createBrowserEntrySearchIndex(entry); + browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); + if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { + const oldestKey = browserEntrySearchIndexCache.keys().next().value; + if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); + } + return index; +} + +function createBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySearchIndex { const searchFields: TokenSearchField[] = [ { value: normalizeForTokenSearch(entry.query), weight: 1.15 }, { value: normalizeForTokenSearch(entry.url, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), weight: 1 }, @@ -1351,11 +1633,6 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear normalizedUrl: normalizeUrlForCompletion(entry.url || entry.host, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), searchFields, }; - browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); - if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { - const oldestKey = browserEntrySearchIndexCache.keys().next().value; - if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); - } return index; } @@ -1572,3 +1849,10 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + filterBrowserResults, + getOrderedBrowserResults, + getRankedBrowserResults, +}; diff --git a/src/renderer/src/hooks/useCursorPrompt.ts b/src/renderer/src/hooks/useCursorPrompt.ts index a601bf1e..27e0647b 100644 --- a/src/renderer/src/hooks/useCursorPrompt.ts +++ b/src/renderer/src/hooks/useCursorPrompt.ts @@ -16,6 +16,10 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { NO_AI_MODEL_ERROR } from '../utils/constants'; +import { + createCursorPromptResultBatcher, + type CursorPromptResultBatcher, +} from './cursorPromptResultBatcher'; // ─── Interfaces ────────────────────────────────────────────────────── @@ -53,7 +57,7 @@ export function useCursorPrompt({ }: UseCursorPromptOptions): UseCursorPromptReturn { const [cursorPromptText, setCursorPromptText] = useState(''); const [cursorPromptStatus, setCursorPromptStatus] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle'); - const [cursorPromptResult, setCursorPromptResult] = useState(''); + const [cursorPromptResult, setCursorPromptResultState] = useState(''); const [cursorPromptError, setCursorPromptError] = useState(''); const [cursorPromptSourceText, setCursorPromptSourceText] = useState(''); @@ -61,6 +65,18 @@ export function useCursorPrompt({ const cursorPromptResultRef = useRef(''); const cursorPromptSourceTextRef = useRef(''); const cursorPromptInputRef = useRef(null); + const cursorPromptResultBatcherRef = useRef(null); + + if (!cursorPromptResultBatcherRef.current) { + cursorPromptResultBatcherRef.current = createCursorPromptResultBatcher({ + resultRef: cursorPromptResultRef, + setVisibleResult: setCursorPromptResultState, + }); + } + + const setCursorPromptResult = useCallback((value: string) => { + cursorPromptResultBatcherRef.current?.reset(value); + }, []); // ── Apply result to the editor ────────────────────────────────── @@ -89,18 +105,19 @@ export function useCursorPrompt({ useEffect(() => { const handleChunk = (data: { requestId: string; chunk: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { - cursorPromptResultRef.current += data.chunk; - setCursorPromptResult((prev) => prev + data.chunk); + cursorPromptResultBatcherRef.current?.appendChunk(data.chunk); } }; const handleDone = (data: { requestId: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; void applyCursorPromptResultToEditor(); } }; const handleError = (data: { requestId: string; error: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; setCursorPromptStatus('error'); setCursorPromptError(data.error || 'Failed to process this prompt.'); @@ -118,6 +135,12 @@ export function useCursorPrompt({ }; }, [applyCursorPromptResultToEditor]); + useEffect(() => { + return () => { + cursorPromptResultBatcherRef.current?.dispose(); + }; + }, []); + // ── Focus cursor prompt input when shown ──────────────────────── useEffect(() => { @@ -203,7 +226,7 @@ export function useCursorPrompt({ `Instruction: ${instruction}`, ].join('\n'); await window.electron.aiAsk(requestId, compositePrompt); - }, [cursorPromptStatus, cursorPromptText, setAiAvailable]); + }, [cursorPromptStatus, cursorPromptText, setAiAvailable, setCursorPromptResult]); const closeCursorPrompt = useCallback(async () => { if (cursorPromptRequestIdRef.current) { @@ -212,6 +235,7 @@ export function useCursorPrompt({ } catch {} cursorPromptRequestIdRef.current = null; } + cursorPromptResultBatcherRef.current?.cancelPendingFlush(); setShowCursorPrompt(false); window.electron.hideWindow(); }, [setShowCursorPrompt]); @@ -223,7 +247,8 @@ export function useCursorPrompt({ setCursorPromptError(''); setCursorPromptSourceText(''); cursorPromptRequestIdRef.current = null; - }, []); + cursorPromptSourceTextRef.current = ''; + }, [setCursorPromptResult]); return { cursorPromptText, diff --git a/src/renderer/src/hooks/useLauncherCommandExecution.ts b/src/renderer/src/hooks/useLauncherCommandExecution.ts index 9fbf3ebd..18563a44 100644 --- a/src/renderer/src/hooks/useLauncherCommandExecution.ts +++ b/src/renderer/src/hooks/useLauncherCommandExecution.ts @@ -3,6 +3,7 @@ import type React from 'react'; import type { CommandInfo, ExtensionBundle, QuickLinkDynamicField } from '../../types/electron'; import { LAST_EXT_KEY } from '../utils/constants'; import { + flushCommandArgumentSettingsSync, getCmdArgsKey, getScriptCmdArgsKey, hydrateExtensionBundlePreferences, @@ -266,10 +267,13 @@ export function useLauncherCommandExecution( }; if (Object.keys(inlineArguments).length > 0 && command.mode === 'no-view') { + const commandArgsKey = getCmdArgsKey(extName, cmdName); writeJsonObject( - getCmdArgsKey(extName, cmdName), - { ...((hydratedWithInlineArguments as any).launchArguments || {}) } + commandArgsKey, + { ...((hydratedWithInlineArguments as any).launchArguments || {}) }, + { commandArgumentSettingsSync: 'debounced' } ); + await flushCommandArgumentSettingsSync(commandArgsKey); } if (shouldOpenCommandSetup(hydratedWithInlineArguments)) { diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..71ad79d9 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -9,7 +9,11 @@ import type { import type { BrowserSearchResult, useBrowserSearch } from './useBrowserSearch'; import type { CalcResult } from '../smart-calculator'; import { tryCalculate, tryCalculateAsync } from '../smart-calculator'; -import { filterCommands, rankCommands } from '../utils/command-helpers'; +import { + createRootCommandScoreIndex, + filterCommands, + rankCommandsWithIndex, +} from '../utils/command-helpers'; import { asTildePath, buildFileResultCommandId, @@ -43,7 +47,6 @@ import { rankRootSearchCandidates, scoreRootSearchCandidate, scoreRootSearchFields, - type MatchKind, type RootSearchCandidate, type RootSearchRankingState, type RootSearchSubtype, @@ -52,6 +55,11 @@ import { assembleRootSearchSections, isRootResultPromotionCandidate, } from '../utils/root-search-sections'; +import { + buildLauncherFileCandidates, + buildLauncherFileResultCommandByPath, + coerceRootSearchMatchKind as coerceMatchKind, +} from '../utils/launcher-file-candidates'; import type { LauncherCommandSection } from '../components/LauncherCommandList'; export type GroupedLauncherCommands = { @@ -138,56 +146,9 @@ function inferCommandSubtype(command: CommandInfo): RootSearchSubtype { return 'system-command'; } -function coerceMatchKind(value: string | undefined, fallback: MatchKind): MatchKind { - switch (value) { - case 'exact': - case 'alias-exact': - case 'nickname-exact': - case 'prefix': - case 'token-prefix': - case 'compact-prefix': - case 'word-boundary-fuzzy': - case 'contains': - case 'subsequence': - case 'description': - case 'path': - case 'url': - return value; - default: - return fallback; - } -} - -function getFileFreshnessBoost(result: IndexedFileSearchResult): number { - const touched = Math.max(Number(result.mtimeMs || 0), Number(result.birthtimeMs || 0)); - if (!touched) return 0; - const ageHours = Math.max(0, (Date.now() - touched) / (60 * 60 * 1000)); - const ageDays = ageHours / 24; - if (ageHours <= 24) return 120; - if (ageDays <= 7) return 90; - if (ageDays <= 30) return 45; - if (ageDays <= 90) return 15; - return 0; -} - -function getFileLocationBoost(result: IndexedFileSearchResult): number { - const topLevelRoot = String(result.topLevelRoot || '').trim(); - const depth = Number(result.homeRelativeDepth || result.depth || 0); - const protectedRoot = topLevelRoot === 'Desktop' || topLevelRoot === 'Documents' || topLevelRoot === 'Downloads'; - if (!protectedRoot) return 20; - return 120 - Math.min(90, Math.max(0, depth - 2) * 18); -} - -function getFileDepthPenalty(result: IndexedFileSearchResult): number { - const depth = Math.max(0, Number(result.homeRelativeDepth || result.depth || 0)); - if (depth <= 2) return 0; - if (depth <= 4) return (depth - 2) * 25; - return Math.min(260, 50 + (depth - 4) * 35); -} - type BrowserLauncherProfile = { id?: string; - browserId?: BrowserSearchSource | string; + browserId?: BrowserSearchSource; displayName: string; detectedName?: string; profileId: string; @@ -339,9 +300,13 @@ export function useLauncherCommandModel({ const calcResult = syncCalcResult ?? asyncCalcResult; const calcOffset = calcResult ? 1 : 0; const contextualCommands = commands; + const hasSearchQuery = searchQuery.trim().length > 0; + const shouldComputeLegacyCommandList = !hasSearchQuery || Boolean(calcResult); const filteredCommands = useMemo( - () => filterCommands(contextualCommands, searchQuery, commandAliases), - [contextualCommands, searchQuery, commandAliases] + () => shouldComputeLegacyCommandList + ? filterCommands(contextualCommands, searchQuery, commandAliases) + : contextualCommands, + [contextualCommands, searchQuery, commandAliases, shouldComputeLegacyCommandList] ); // When calculator is showing but no commands match, show unfiltered list below. @@ -355,7 +320,20 @@ export function useLauncherCommandModel({ () => new Set(['system-add-to-memory', 'system-cursor-prompt', 'system-emoji-picker']), [] ); - const hasSearchQuery = searchQuery.trim().length > 0; + const shouldIndexRootCommands = hasSearchQuery && !aiMode && rootBangState.mode === 'none'; + const rootSearchableCommands = useMemo( + () => shouldIndexRootCommands + ? contextualCommands.filter((cmd) => + cmd.id !== WEB_SEARCH_COMMAND_ID && + (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) + ) + : [], + [contextualCommands, hiddenListOnlyCommandIds, hasSearchQuery, shouldIndexRootCommands] + ); + const rootCommandScoreIndex = useMemo( + () => createRootCommandScoreIndex(rootSearchableCommands, commandAliases), + [rootSearchableCommands, commandAliases] + ); const visibleSourceCommands = useMemo( () => sourceCommands .filter((cmd) => !hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) @@ -391,6 +369,10 @@ export function useLauncherCommandModel({ }), [launcherFileResults, launcherFileIcons, homeDir] ); + const fileResultCommandByPath = useMemo( + () => buildLauncherFileResultCommandByPath(fileResultCommands), + [fileResultCommands] + ); const pinnedFileCommands = useMemo( () => @@ -521,21 +503,9 @@ export function useLauncherCommandModel({ const browserSearchResultCommands = useMemo(() => [], []); const commandCandidates = useMemo(() => { - if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - const searchableCommands = contextualCommands.filter((cmd) => - cmd.id !== WEB_SEARCH_COMMAND_ID && - (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) - ); - return rankCommands(searchableCommands, searchQuery, commandAliases) - .map(({ command }) => { - const alias = commandAliases[command.id] || ''; - const scored = scoreRootSearchFields(searchQuery, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), - ]); - if (!scored.matched) return null; + if (!shouldIndexRootCommands) return []; + return rankCommandsWithIndex(rootCommandScoreIndex, searchQuery) + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; return scoreRootSearchCandidate({ @@ -551,8 +521,8 @@ export function useLauncherCommandModel({ label: command.title, description: command.subtitle, pathOrUrl: command.path, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + matchKind, + matchScore, sourceQualityBoost: command.alwaysOnTop ? 80 : 0, freshnessBoost: 0, pathLocationBoost: 0, @@ -561,49 +531,17 @@ export function useLauncherCommandModel({ }, searchQuery, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, contextualCommands, hiddenListOnlyCommandIds, searchQuery, commandAliases, rootSearchRanking]); + }, [shouldIndexRootCommands, rootCommandScoreIndex, searchQuery, rootSearchRanking]); const fileCandidates = useMemo(() => { if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - return launcherFileResults - .map((result) => { - const command = fileResultCommands.find((item) => item.path === result.path); - if (!command) return null; - const scored = scoreRootSearchFields(searchQuery, [ - { value: result.name, kind: 'label', weight: 1 }, - { value: result.parentPath, kind: 'path', weight: 0.72 }, - { value: result.displayPath, kind: 'path', weight: 0.72 }, - { value: result.path, kind: 'path', weight: 0.68 }, - ]); - if (!scored.matched) return null; - const subtype: RootSearchSubtype = result.isDirectory ? 'folder' : 'file'; - const matchKind = coerceMatchKind(result.matchKind, scored.matchKind); - const weakFolderMatch = subtype === 'folder' && (matchKind === 'contains' || matchKind === 'subsequence' || matchKind === 'path'); - const stableKey = `file:${normalizeRootSearchStableValue(result.path)}`; - return scoreRootSearchCandidate({ - command: { - ...command, - rootSearchStableKey: stableKey, - rootSearchSource: 'file', - rootSearchSubtype: subtype, - }, - source: 'file', - subtype, - stableKey, - label: result.name, - description: result.displayPath, - pathOrUrl: result.path, - matchKind, - matchScore: scored.matchScore, - sourceQualityBoost: subtype === 'file' ? 8 : weakFolderMatch ? -10 : 0, - freshnessBoost: getFileFreshnessBoost(result), - pathLocationBoost: getFileLocationBoost(result), - noisePenalty: Math.max(0, Number(result.noisyPathSegmentCount || 0)) * 70, - depthPenalty: getFileDepthPenalty(result), - }, searchQuery, rootSearchRanking); - }) - .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, launcherFileResults, fileResultCommands, searchQuery, rootSearchRanking]); + return buildLauncherFileCandidates({ + launcherFileResults, + fileResultCommandByPath, + searchQuery, + rootSearchRanking, + }); + }, [hasSearchQuery, aiMode, rootBangState, launcherFileResults, fileResultCommandByPath, searchQuery, rootSearchRanking]); const browserCandidates = useMemo(() => { if (!browserSearch.enabled || !browserSearch.alphaChromiumRootSearchEnabled || !hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; @@ -857,41 +795,6 @@ export function useLauncherCommandModel({ queryFileSectionCommands, } = rootSearchSectionAssembly; - // TEMP DIAGNOSTIC (SC-RANK2): pinpoint whether the internal>browser comparator - // is actually running, and whether Search Notes / Create Note are candidates. - // Remove once the override bug is confirmed fixed. - useEffect(() => { - if (!hasSearchQuery) return; - try { - const idOf = (c: any) => String(c?.command?.id || c?.stableKey || ''); - const sn = commandCandidates.find((c) => idOf(c).includes('search-notes')); - const cn = commandCandidates.find((c) => idOf(c).includes('create-note')); - const internalCount = rootRankedCandidates.filter((c) => !c.isOrganicBrowserResult).length; - const browserCount = rootRankedCandidates.filter((c) => c.isOrganicBrowserResult).length; - const firstBrowserIdx = rootRankedCandidates.findIndex((c) => c.isOrganicBrowserResult); - const snIdx = rootRankedCandidates.findIndex((c) => idOf(c).includes('search-notes')); - const cnIdx = rootRankedCandidates.findIndex((c) => idOf(c).includes('create-note')); - const describe = (c: any, idx: number) => c - ? { score: Math.round(c.finalScore), matchKind: c.matchKind, organic: c.isOrganicBrowserResult, rankIdx: idx } - : 'NOT_A_CANDIDATE'; - (window as any).electron?.whisperDebugLog?.('SC-RANK2', `q="${searchQuery}"`, { - cmdCands: commandCandidates.length, - browserCands: browserCandidates.length, - rootTotal: rootRankedCandidates.length, - internalCount, - browserCount, - // If the comparator works, firstBrowserIdx === internalCount (all internal first). - firstBrowserIdx, - comparatorWorking: firstBrowserIdx === -1 || firstBrowserIdx >= internalCount, - searchNotes: describe(sn, snIdx), - createNote: describe(cn, cnIdx), - RESULTS: queryResultCommands.map((c) => c.title), - }); - } catch (e) { - (window as any).electron?.whisperDebugLog?.('SC-RANK2', 'ERR', String(e)); - } - }, [hasSearchQuery, searchQuery, rootRankedCandidates, queryResultCommands, commandCandidates, browserCandidates]); - const displayCommands = useMemo(() => { if (rootBangState.mode === 'selecting') return rootSearchSectionAssembly.displayCommands; if (rootBangState.mode === 'active') return rootSearchSectionAssembly.displayCommands; diff --git a/src/renderer/src/hooks/useLauncherInlineArguments.ts b/src/renderer/src/hooks/useLauncherInlineArguments.ts index 8894bbff..35da8f03 100644 --- a/src/renderer/src/hooks/useLauncherInlineArguments.ts +++ b/src/renderer/src/hooks/useLauncherInlineArguments.ts @@ -272,7 +272,11 @@ export function useLauncherInlineArguments({ [argumentName]: value, }; if (extensionIdentity && command.mode === 'no-view') { - writeJsonObject(getCmdArgsKey(extensionIdentity.extName, extensionIdentity.cmdName), nextCommandValues); + writeJsonObject( + getCmdArgsKey(extensionIdentity.extName, extensionIdentity.cmdName), + nextCommandValues, + { commandArgumentSettingsSync: 'debounced' } + ); } return { ...prev, diff --git a/src/renderer/src/hooks/useLauncherKeyboardControls.ts b/src/renderer/src/hooks/useLauncherKeyboardControls.ts index a385562f..e965d9f1 100644 --- a/src/renderer/src/hooks/useLauncherKeyboardControls.ts +++ b/src/renderer/src/hooks/useLauncherKeyboardControls.ts @@ -91,6 +91,7 @@ export type UseLauncherKeyboardControlsOptions = { kind?: CommandInfo['browserResultKind']; url?: string; sourceProfileId?: string; + openInSourceProfile?: boolean; windowId?: string | number; tabId?: string | number; } diff --git a/src/renderer/src/hooks/useMenuBarExtensions.ts b/src/renderer/src/hooks/useMenuBarExtensions.ts index cf912a32..a638c8ba 100644 --- a/src/renderer/src/hooks/useMenuBarExtensions.ts +++ b/src/renderer/src/hooks/useMenuBarExtensions.ts @@ -19,20 +19,16 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import type { ExtensionBundle } from '../../types/electron'; +import type { ExtensionStorageChangedDetail } from '../raycast-api/storage-events'; +import type { BackgroundNoViewRun } from '../utils/background-no-view-runs'; + +export type { BackgroundNoViewRun } from '../utils/background-no-view-runs'; export interface MenuBarEntry { key: string; bundle: ExtensionBundle; } -export interface BackgroundNoViewRun { - runId: string; - bundle: ExtensionBundle; - launchType: 'userInitiated' | 'background'; - /** When true, execution status is mirrored to the system status-bar badge. */ - reportStatus?: boolean; -} - export interface UseMenuBarExtensionsReturn { menuBarExtensions: MenuBarEntry[]; backgroundNoViewRuns: BackgroundNoViewRun[]; @@ -46,7 +42,13 @@ export interface UseMenuBarExtensionsReturn { hideMenuBarExtension: (bundle: Partial) => void; hideMenuBarExtensionsForExtension: (extensionName: string) => void; upsertMenuBarExtension: (bundle: ExtensionBundle, options?: { remount?: boolean }) => void; - remountMenuBarExtensionsForExtension: (extensionName: string) => void; + remountMenuBarExtensionsForExtension: ( + extensionName: string, + options?: { + sourceCommandName?: string; + sourceCommandMode?: string; + } + ) => void; } export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { @@ -133,25 +135,35 @@ export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { }); }, [getMenuBarIdentity]); - const remountMenuBarExtensionsForExtension = useCallback((extensionName: string) => { + const remountMenuBarExtensionsForExtension = useCallback(( + extensionName: string, + options?: { + sourceCommandName?: string; + sourceCommandMode?: string; + } + ) => { const normalized = (extensionName || '').trim(); if (!normalized) return; const now = Date.now(); const lastTs = menuBarRemountTimestampsRef.current[normalized] || 0; if (now - lastTs < 200) return; - menuBarRemountTimestampsRef.current[normalized] = now; + const sourceCommandName = (options?.sourceCommandName || '').trim(); + const sourceCommandMode = (options?.sourceCommandMode || '').trim(); + const skipSourceCommand = sourceCommandMode === 'menu-bar' && Boolean(sourceCommandName); setMenuBarExtensions((prev) => { let changed = false; const next = prev.map((entry) => { const entryExt = (entry.bundle.extName || entry.bundle.extensionName || '').trim(); if (!entryExt || entryExt !== normalized) return entry; + const cmdName = (entry.bundle.cmdName || entry.bundle.commandName || '').trim(); + if (skipSourceCommand && cmdName === sourceCommandName) return entry; changed = true; - const cmdName = entry.bundle.cmdName || entry.bundle.commandName || ''; return { key: `${normalized}:${cmdName}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, bundle: entry.bundle, }; }); + if (changed) menuBarRemountTimestampsRef.current[normalized] = now; return changed ? next : prev; }); }, []); @@ -163,10 +175,13 @@ export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { // This matches Raycast behavior where menu-bar commands observe state changes quickly. useEffect(() => { const onStorageChanged = (event: Event) => { - const custom = event as CustomEvent<{ extensionName?: string }>; + const custom = event as CustomEvent>; const extensionName = (custom.detail?.extensionName || '').trim(); if (!extensionName) return; - remountMenuBarExtensionsForExtension(extensionName); + remountMenuBarExtensionsForExtension(extensionName, { + sourceCommandName: custom.detail?.commandName, + sourceCommandMode: custom.detail?.commandMode, + }); }; window.addEventListener('sc-extension-storage-changed', onStorageChanged as EventListener); return () => { 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/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index 4f57f777..149eb5c3 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -1,6 +1,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { DEFAULT_APP_LANGUAGE, + loadAppLocale, normalizeAppLanguage, resolveAppLocale, translateMessage, @@ -19,6 +20,7 @@ const I18nContext = createContext(null); export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [language, setLanguage] = useState(DEFAULT_APP_LANGUAGE); + const [catalogVersion, setCatalogVersion] = useState(0); useEffect(() => { let disposed = false; @@ -45,9 +47,28 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children [language] ); + useEffect(() => { + let disposed = false; + loadAppLocale(locale) + .then(() => { + if (!disposed) { + setCatalogVersion((version) => version + 1); + } + }) + .catch((error) => { + if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') { + console.warn(`[i18n] Failed to load locale "${locale}"`, error); + } + }); + + return () => { + disposed = true; + }; + }, [locale]); + const t = useCallback( (key: string, values?: TranslationValues) => translateMessage(locale, key, values), - [locale] + [locale, catalogVersion] ); const value = useMemo(() => ({ language, locale, t }), [language, locale, t]); diff --git a/src/renderer/src/i18n/locales/it.json b/src/renderer/src/i18n/locales/it.json index 75ae34be..82538b88 100644 --- a/src/renderer/src/i18n/locales/it.json +++ b/src/renderer/src/i18n/locales/it.json @@ -375,6 +375,12 @@ "elevenlabsWarning": "STT di ElevenLabs selezionato. {action} la chiave API di ElevenLabs in Chiavi API.", "elevenlabsReady": "STT di ElevenLabs selezionato. La trascrizione cloud userà la tua chiave ElevenLabs.", "recognitionLanguage": "Lingua di riconoscimento", + "vocabulary": { + "label": "Vocabolario personalizzato", + "placeholder": "Kotlin, Electron, Raycast, SuperCmd…", + "hint": "Facoltativo. Elenca parole poco comuni, nomi o termini tecnici (separati da virgole o da nuove righe) per orientare il riconoscimento verso l'ortografia corretta.", + "tokenWarning": "Circa {count} token. Whisper usa all'incirca solo i primi 224: le parole oltre quel limite vengono ignorate." + }, "providerInfo": { "parakeet": "Trascrizione offline sul dispositivo con Parakeet TDT v3. Viene eseguita su Apple Neural Engine. Richiede un riscaldamento del modello al primo utilizzo. Scarica il modello qui sotto prima di usare la dettatura.", "qwen3": "Trascrizione offline sul dispositivo con Qwen3 ASR. Supporta oltre 30 lingue, richiede macOS 15+ e si riscalda al primo utilizzo.", @@ -611,6 +617,18 @@ "openedExisting": "Cartella degli script personalizzati aperta.", "failed": "Impossibile aprire la cartella degli script personalizzati." }, + "appSearchScope": { + "title": "Ambito di ricerca delle applicazioni", + "description": "Cartelle in cui SuperCmd cerca le applicazioni installate da mostrare nel launcher.", + "add": "Aggiungi cartella", + "remove": "Rimuovi", + "empty": "Nessuna cartella aggiunta. Verranno usate le cartelle applicazioni di sistema predefinite.", + "added": "Cartella aggiunta all'ambito di ricerca.", + "removed": "Cartella rimossa dall'ambito di ricerca.", + "duplicate": "Cartella già presente nell'elenco.", + "failed": "Impossibile aggiornare l'ambito di ricerca.", + "restartHint": "Riavvia SuperCmd affinché le modifiche alle cartelle abbiano effetto." + }, "emojiPicker": { "enabled": "Abilita il selettore emoji", "enabledDesc": "Mostra suggerimenti emoji quando si digita un prefisso trigger.", diff --git a/src/renderer/src/i18n/runtime.ts b/src/renderer/src/i18n/runtime.ts index edf181c9..7f278337 100644 --- a/src/renderer/src/i18n/runtime.ts +++ b/src/renderer/src/i18n/runtime.ts @@ -1,36 +1,37 @@ import enMessages from './locales/en.json'; -import zhHansMessages from './locales/zh-Hans.json'; -import zhHantMessages from './locales/zh-Hant.json'; -import jaMessages from './locales/ja.json'; -import koMessages from './locales/ko.json'; -import frMessages from './locales/fr.json'; -import deMessages from './locales/de.json'; -import esMessages from './locales/es.json'; -import ruMessages from './locales/ru.json'; -import itMessages from './locales/it.json'; export type SupportedAppLocale = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'fr' | 'de' | 'es' | 'ru' | 'it'; export type AppLanguageSetting = 'system' | SupportedAppLocale; export type TranslationValues = Record; -type MessageTree = Record; +interface MessageTree { + [key: string]: string | MessageTree; +} export const DEFAULT_APP_LANGUAGE: AppLanguageSetting = 'system'; export const FALLBACK_APP_LOCALE: SupportedAppLocale = 'en'; export const APP_LANGUAGE_OPTIONS: AppLanguageSetting[] = ['system', 'en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'fr', 'de', 'es', 'ru', 'it']; -const MESSAGE_CATALOG: Record = { +type LocaleModule = { default: MessageTree } | MessageTree; + +const MESSAGE_CATALOG: Partial> = { en: enMessages as MessageTree, - 'zh-Hans': zhHansMessages as MessageTree, - 'zh-Hant': zhHantMessages as MessageTree, - 'ja': jaMessages as MessageTree, - 'ko': koMessages as MessageTree, - 'fr': frMessages as MessageTree, - 'de': deMessages as MessageTree, - 'es': esMessages as MessageTree, - 'ru': ruMessages as MessageTree, - 'it': itMessages as MessageTree, }; +const LOCALE_LOADERS: Record Promise> = { + en: () => Promise.resolve(enMessages as MessageTree), + 'zh-Hans': () => import('./locales/zh-Hans.json'), + 'zh-Hant': () => import('./locales/zh-Hant.json'), + ja: () => import('./locales/ja.json'), + ko: () => import('./locales/ko.json'), + fr: () => import('./locales/fr.json'), + de: () => import('./locales/de.json'), + es: () => import('./locales/es.json'), + ru: () => import('./locales/ru.json'), + it: () => import('./locales/it.json'), +}; + +const PENDING_LOCALE_LOADS: Partial>> = {}; + function resolveMessage(tree: MessageTree, key: string): string | null { const segments = String(key || '') .split('.') @@ -52,6 +53,36 @@ function interpolateMessage(template: string, values?: TranslationValues): strin }); } +function readLocaleModule(module: LocaleModule): MessageTree { + return ('default' in module ? module.default : module) as MessageTree; +} + +export function isAppLocaleLoaded(locale: SupportedAppLocale): boolean { + return Boolean(MESSAGE_CATALOG[locale]); +} + +export function loadAppLocale(locale: SupportedAppLocale): Promise { + const loadedCatalog = MESSAGE_CATALOG[locale]; + if (loadedCatalog) return Promise.resolve(loadedCatalog); + + const pendingLoad = PENDING_LOCALE_LOADS[locale]; + if (pendingLoad) return pendingLoad; + + const loader = LOCALE_LOADERS[locale]; + const load = loader() + .then((module) => { + const catalog = readLocaleModule(module); + MESSAGE_CATALOG[locale] = catalog; + return catalog; + }) + .finally(() => { + delete PENDING_LOCALE_LOADS[locale]; + }); + + PENDING_LOCALE_LOADS[locale] = load; + return load; +} + export function normalizeAppLanguage(value: unknown): AppLanguageSetting { const normalized = String(value || '') .trim() @@ -123,9 +154,11 @@ export function translateMessage( key: string, values?: TranslationValues ): string { + const localeCatalog = MESSAGE_CATALOG[locale]; + const fallbackCatalog = MESSAGE_CATALOG[FALLBACK_APP_LOCALE]; const localeMessage = - resolveMessage(MESSAGE_CATALOG[locale], key) ?? - resolveMessage(MESSAGE_CATALOG[FALLBACK_APP_LOCALE], key); + (localeCatalog ? resolveMessage(localeCatalog, key) : null) ?? + (fallbackCatalog ? resolveMessage(fallbackCatalog, key) : null); if (localeMessage == null) { if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') { console.warn(`[i18n] Missing translation key: "${key}"`); diff --git a/src/renderer/src/raycast-api/action-runtime-overlay.tsx b/src/renderer/src/raycast-api/action-runtime-overlay.tsx index 02896d10..e623136c 100644 --- a/src/renderer/src/raycast-api/action-runtime-overlay.tsx +++ b/src/renderer/src/raycast-api/action-runtime-overlay.tsx @@ -112,7 +112,8 @@ export function createActionOverlayRuntime(deps: OverlayDeps) { } if (source && typeof source === 'object') { - const variants = [source.light, source.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + const sourceVariants = source as Record; + const variants = [sourceVariants.light, sourceVariants.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); if (variants.length > 0) { const assetLikeVariants = variants.filter((value) => hasImageExtension(value)); if (assetLikeVariants.length === 0) return true; diff --git a/src/renderer/src/raycast-api/action-runtime-registry.tsx b/src/renderer/src/raycast-api/action-runtime-registry.tsx index 5c8c8db8..e362ffb7 100644 --- a/src/renderer/src/raycast-api/action-runtime-registry.tsx +++ b/src/renderer/src/raycast-api/action-runtime-registry.tsx @@ -348,14 +348,30 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { function useCollectedActions() { const registryRef = useRef(new Map()); const [version, setVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + lastSnapshotRef.current = ''; + }; + }, []); + const scheduleUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const entries = Array.from(registryRef.current.values()); const snapshot = entries.map((entry) => `${entry.id}:${entry.title}:${entry.sectionTitle || ''}`).join('|'); @@ -369,6 +385,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { const registryAPI = useMemo( () => ({ register(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { existing.title = data.title; @@ -384,6 +401,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { scheduleUpdate(); }, unregister(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleUpdate(); diff --git a/src/renderer/src/raycast-api/context-scope-runtime.ts b/src/renderer/src/raycast-api/context-scope-runtime.ts index 440b0bc1..410737cc 100644 --- a/src/renderer/src/raycast-api/context-scope-runtime.ts +++ b/src/renderer/src/raycast-api/context-scope-runtime.ts @@ -89,7 +89,7 @@ export function withExtensionContext(ctx: ExtensionContextSnapshot | undefine try { const value = fn(); if (value && typeof (value as any).then === 'function') { - return (value as Promise).finally(restore) as T; + return (value as unknown as Promise).finally(restore) as T; } restore(); return value; diff --git a/src/renderer/src/raycast-api/detail-runtime.tsx b/src/renderer/src/raycast-api/detail-runtime.tsx index 88d513ff..0566bb26 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -90,7 +90,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { for (const node of allChildren) { if (React.isValidElement(node)) { - const typeRecord = node.type as Record | null; + const typeRecord = node.type as unknown as Record | null; if (typeRecord?.[DETAIL_METADATA_RUNTIME_MARKER] === true) { metadataNodes.push(node); continue; @@ -290,7 +290,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { ); const MetadataComponent = ({ children }: { children?: React.ReactNode }) =>
{children}
; - (MetadataComponent as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; + (MetadataComponent as unknown as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; MetadataComponent.displayName = 'Detail.Metadata'; const Metadata = Object.assign( diff --git a/src/renderer/src/raycast-api/form-runtime-context.tsx b/src/renderer/src/raycast-api/form-runtime-context.tsx index 9545c387..33e5da74 100644 --- a/src/renderer/src/raycast-api/form-runtime-context.tsx +++ b/src/renderer/src/raycast-api/form-runtime-context.tsx @@ -28,19 +28,54 @@ export const FormContext = createContext({ let currentFormValues: Record = {}; let currentFormErrors: Record = {}; let currentFormPlaceholders: Record = {}; +let currentFormSnapshotOwner: symbol | null = null; -export function setCurrentFormValues(values: Record) { +export function setCurrentFormValues(values: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormValues = values; } -export function setCurrentFormErrors(errors: Record) { +export function setCurrentFormErrors(errors: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormErrors = errors; } -export function setCurrentFormPlaceholders(placeholders: Record) { +export function setCurrentFormPlaceholders(placeholders: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormPlaceholders = placeholders; } +export function clearCurrentFormSnapshot(owner?: symbol) { + if (owner && currentFormSnapshotOwner && currentFormSnapshotOwner !== owner) { + return false; + } + + currentFormSnapshotOwner = null; + currentFormValues = {}; + currentFormErrors = {}; + currentFormPlaceholders = {}; + return true; +} + +export function getFormSnapshotRetentionMetrics() { + const serialized = JSON.stringify({ + values: currentFormValues, + errors: currentFormErrors, + placeholders: currentFormPlaceholders, + }); + + return { + valueKeys: Object.keys(currentFormValues).length, + errorKeys: Object.keys(currentFormErrors).length, + placeholderKeys: Object.keys(currentFormPlaceholders).length, + totalKeys: + Object.keys(currentFormValues).length + + Object.keys(currentFormErrors).length + + Object.keys(currentFormPlaceholders).length, + serializedBytes: new TextEncoder().encode(serialized).byteLength, + }; +} + export function getFormValues(): Record { // Fall back to placeholder for fields the user didn't fill. Lets extensions // declare a sensible default like `placeholder="0"` and have it submitted diff --git a/src/renderer/src/raycast-api/form-runtime-state.ts b/src/renderer/src/raycast-api/form-runtime-state.ts new file mode 100644 index 00000000..1da503fc --- /dev/null +++ b/src/renderer/src/raycast-api/form-runtime-state.ts @@ -0,0 +1,19 @@ +export type FormErrorMap = Record; + +export function clearFormFieldError(previous: FormErrorMap, id: string): FormErrorMap { + if (!Object.prototype.hasOwnProperty.call(previous, id)) { + return previous; + } + + const next = { ...previous }; + delete next[id]; + return next; +} + +export function setFormFieldError(previous: FormErrorMap, id: string, error: string): FormErrorMap { + if (previous[id] === error) { + return previous; + } + + return { ...previous, [id]: error }; +} diff --git a/src/renderer/src/raycast-api/form-runtime.tsx b/src/renderer/src/raycast-api/form-runtime.tsx index 46ddbf0f..e096db50 100644 --- a/src/renderer/src/raycast-api/form-runtime.tsx +++ b/src/renderer/src/raycast-api/form-runtime.tsx @@ -5,9 +5,11 @@ * all field subcomponents. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { attachFormFields } from './form-runtime-fields'; +import { clearFormFieldError, setFormFieldError } from './form-runtime-state'; import { + clearCurrentFormSnapshot, FormContext, setCurrentFormErrors, setCurrentFormPlaceholders, @@ -56,44 +58,53 @@ export function createFormRuntime(deps: FormRuntimeDeps) { const [errors, setErrors] = useState>({}); const [placeholders, setPlaceholders] = useState>({}); const [showActions, setShowActions] = useState(false); + const formSnapshotOwnerRef = useRef(Symbol('FormRuntime')); + const formSnapshotOwner = formSnapshotOwnerRef.current; const { pop } = useNavigation(); const setValue = useCallback((id: string, value: any) => { setValues((previous) => { const next = { ...previous, [id]: value }; - setCurrentFormValues(next); + setCurrentFormValues(next, formSnapshotOwner); return next; }); setErrors((previous) => { - const next = { ...previous }; - delete next[id]; - setCurrentFormErrors(next); + const next = clearFormFieldError(previous, id); + if (next === previous) return previous; + setCurrentFormErrors(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); const setError = useCallback((id: string, error: string) => { setErrors((previous) => { - const next = { ...previous, [id]: error }; - setCurrentFormErrors(next); + const next = setFormFieldError(previous, id, error); + if (next === previous) return previous; + setCurrentFormErrors(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); const setPlaceholder = useCallback((id: string, placeholder: string) => { setPlaceholders((previous) => { if (previous[id] === placeholder) return previous; const next = { ...previous, [id]: placeholder }; - setCurrentFormPlaceholders(next); + setCurrentFormPlaceholders(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); useEffect(() => { - setCurrentFormValues(values); - setCurrentFormErrors(errors); - setCurrentFormPlaceholders(placeholders); - }, [values, errors, placeholders]); + setCurrentFormValues(values, formSnapshotOwner); + setCurrentFormErrors(errors, formSnapshotOwner); + setCurrentFormPlaceholders(placeholders, formSnapshotOwner); + }, [errors, formSnapshotOwner, placeholders, values]); + + useEffect(() => { + return () => { + clearCurrentFormSnapshot(formSnapshotOwner); + }; + }, [formSnapshotOwner]); const { collectedActions: formActions, registryAPI: formActionRegistry } = useCollectedActions(); const primaryAction = formActions[0]; diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..d301f7d1 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,50 +4,181 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; +export interface GridItemGroup { + key: string; + title?: string; + subtitle?: string; + section?: GridItemRegistration['section']; + items: { item: GridItemRegistration; globalIdx: number }[]; +} + +function getReactTypeName(type: any): string { + return String(type?.displayName || type?.name || type || ''); +} + +function buildValueSignature(value: unknown, seen = new WeakSet()): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (typeof value === 'function') return `fn:${value.name || 'anonymous'}`; + if (typeof value === 'symbol') return value.toString(); + + if (Array.isArray(value)) { + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; + } + + if (React.isValidElement(value)) { + return `element:${getReactTypeName(value.type)}:${buildValueSignature((value as any).props, seen)}`; + } + + if (typeof value === 'object') { + if (value instanceof Date) return `date:${value.getTime()}`; + if (seen.has(value as object)) return '[circular]'; + seen.add(value as object); + + const entries = Object.entries(value as Record) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function buildImageLikeSignature(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value) || React.isValidElement(value)) { + return buildValueSignature(value); + } + const image = value as Record; + return [ + buildValueSignature(image.source), + buildValueSignature(image.value), + buildValueSignature(image.fileIcon), + buildValueSignature(image.light), + buildValueSignature(image.dark), + buildValueSignature(image.color), + buildValueSignature(image.tintColor), + buildValueSignature(image.mask), + buildValueSignature(image.fallback), + ].join('\u001e'); +} + +function buildGridContentSignature(content: unknown): string { + return buildImageLikeSignature(content); +} + +function buildGridAccessorySignature(accessory: unknown): string { + if (!accessory || typeof accessory !== 'object' || Array.isArray(accessory) || React.isValidElement(accessory)) { + return buildValueSignature(accessory); + } + const value = accessory as Record; + return [ + buildImageLikeSignature(value.icon), + buildValueSignature(value.tooltip), + buildValueSignature(value.text), + buildValueSignature(value.tag), + ].join('\u001e'); +} + +function buildQuickLookSignature(quickLook: GridItemRegistration['props']['quickLook']): string { + if (!quickLook) return ''; + return `${quickLook.name || ''}\u001e${quickLook.path || ''}`; +} + +export function buildGridItemVisibleSignature(entry: GridItemRegistration): string { + const props = entry.props; + const section = entry.section; + return [ + entry.id, + String(entry.order), + section?.id || '', + section?.title || '', + section?.subtitle || '', + section?.columns || '', + section?.aspectRatio || '', + section?.fit || '', + section?.inset || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildGridContentSignature(props.content), + buildGridAccessorySignature(props.accessory), + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildQuickLookSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + export function useGridRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); + + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + }; + }, []); const scheduleRegistryUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { - pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()) - .map((entry) => { - const actionType = entry.props.actions?.type as any; - const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`; - }) - .join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); + if (!mountedRef.current) { + pendingRef.current = false; + return; } + pendingRef.current = false; + setRegistryVersion((value) => value + 1); }); }, []); const registryAPI = useMemo( () => ({ set(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { + const propsChanged = existing.props !== data.props; + const sectionChanged = existing.section !== data.section; + const orderChanged = existing.order !== data.order; existing.props = data.props; - existing.sectionTitle = data.sectionTitle; + existing.section = data.section; existing.order = data.order; + if (!propsChanged && !sectionChanged && !orderChanged) return; + + const nextSignature = buildGridItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const entry = { id, ...data }; + registryRef.current.set(id, entry); + visibleSignatureRef.current.set(id, buildGridItemVisibleSignature(entry)); } scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), @@ -63,14 +194,22 @@ export function useGridRegistry() { } export function groupGridItems(filteredItems: GridItemRegistration[]) { - const groups: { title?: string; items: { item: GridItemRegistration; globalIdx: number }[] }[] = []; - let currentSection: string | undefined | null = null; + const groups: GridItemGroup[] = []; + let currentSectionKey: string | undefined | null = null; let globalIndex = 0; for (const item of filteredItems) { - if (item.sectionTitle !== currentSection || groups.length === 0) { - currentSection = item.sectionTitle; - groups.push({ title: item.sectionTitle, items: [] }); + const section = item.section; + const sectionKey = section?.id || section?.title || '__default_grid_section'; + if (sectionKey !== currentSectionKey || groups.length === 0) { + currentSectionKey = sectionKey; + groups.push({ + key: sectionKey, + title: section?.title, + subtitle: section?.subtitle, + section, + items: [], + }); } groups[groups.length - 1].items.push({ item, globalIdx: globalIndex++ }); } diff --git a/src/renderer/src/raycast-api/grid-runtime-items.tsx b/src/renderer/src/raycast-api/grid-runtime-items.tsx index d06d0269..83d5eeb4 100644 --- a/src/renderer/src/raycast-api/grid-runtime-items.tsx +++ b/src/renderer/src/raycast-api/grid-runtime-items.tsx @@ -4,10 +4,134 @@ * Contains grid item registration contexts and row/cell renderers. */ -import React, { createContext, useContext, useLayoutEffect, useRef } from 'react'; +import React, { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'react'; import { resolveTintColor } from './icon-runtime-assets'; import { renderIcon } from './icon-runtime-render'; +export interface GridSectionRegistration { + id: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + +type ResolveGridIconSource = (src: string) => string; + +function isImageLikeSourceString(value: string): boolean { + const source = String(value || '').trim(); + if (!source) return false; + if ( + source.startsWith('http') || + source.startsWith('data:') || + source.startsWith('sc-asset:') || + source.startsWith('file://') || + source.startsWith('/') || + /^[a-zA-Z]:[\\/]/.test(source) || + source.startsWith('\\\\') + ) { + return true; + } + return /\.(svg|png|jpe?g|gif|webp|ico|tiff?)(\?.*)?$/i.test(source); +} + +function getGridColor(value: any): string | null { + if (!value || typeof value !== 'object') return null; + const hasVisualSource = value.source !== undefined || value.value !== undefined || value.fileIcon !== undefined; + if (hasVisualSource) return null; + return resolveTintColor(value.color) || null; +} + +function normalizeGridSourceString(value: string, resolveIconSrc: ResolveGridIconSource): string { + const source = String(value || '').trim(); + if (!source) return ''; + const resolved = resolveIconSrc(source); + return resolved || source; +} + +function toRenderableGridContent(value: any, resolveIconSrc: ResolveGridIconSource): any { + if (!value) return null; + if (typeof value === 'string') { + const normalized = normalizeGridSourceString(value, resolveIconSrc); + return normalized || null; + } + + if (typeof value !== 'object') return null; + + if (typeof value.fileIcon === 'string' && value.fileIcon.trim()) { + return { fileIcon: value.fileIcon.trim() }; + } + + if (value.source !== undefined) { + const sourceValue = value.source; + if (typeof sourceValue === 'string') { + const normalizedSource = normalizeGridSourceString(sourceValue, resolveIconSrc); + if (!normalizedSource) return null; + const sourceTint = + value.tintColor + || (!isImageLikeSourceString(normalizedSource) ? value.color : undefined); + return { + source: normalizedSource, + tintColor: sourceTint, + mask: value.mask, + fallback: value.fallback, + }; + } + if (sourceValue && typeof sourceValue === 'object') { + return { + source: sourceValue, + tintColor: value.tintColor, + mask: value.mask, + fallback: value.fallback, + }; + } + } + + if (value.value !== undefined) { + const nestedValue = value.value; + if (typeof nestedValue === 'string') { + const normalizedNested = normalizeGridSourceString(nestedValue, resolveIconSrc); + if (!normalizedNested) return null; + const nestedTint = + !isImageLikeSourceString(normalizedNested) ? value.color : undefined; + return nestedTint + ? { source: normalizedNested, tintColor: nestedTint } + : normalizedNested; + } + if (nestedValue && typeof nestedValue === 'object') { + if (typeof nestedValue.fileIcon === 'string' && nestedValue.fileIcon.trim()) { + return { fileIcon: nestedValue.fileIcon.trim() }; + } + + if (nestedValue.source !== undefined) { + return nestedValue; + } + + if (nestedValue.light !== undefined || nestedValue.dark !== undefined) { + return { source: nestedValue }; + } + } + } + + return null; +} + +function areGridItemRendererPropsEqual(previous: any, next: any): boolean { + return ( + previous.title === next.title + && previous.subtitle === next.subtitle + && previous.content === next.content + && previous.accessory === next.accessory + && previous.isSelected === next.isSelected + && previous.dataIdx === next.dataIdx + && previous.itemHeight === next.itemHeight + && previous.fit === next.fit + && previous.inset === next.inset + ); +} + export interface GridItemRegistration { id: string; props: { @@ -20,7 +144,7 @@ export interface GridItemRegistration { accessory?: any; quickLook?: { name?: string; path: string }; }; - sectionTitle?: string; + section?: GridSectionRegistration; order: number; } @@ -31,133 +155,66 @@ export interface GridRegistryAPI { export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) { let gridItemOrderCounter = 0; + let gridSectionOrderCounter = 0; const GridRegistryContext = createContext({ set: () => {}, delete: () => {}, }); - const GridSectionTitleContext = createContext(undefined); + const GridSectionContext = createContext(undefined); function GridItemComponent(props: any) { const registry = useContext(GridRegistryContext); - const sectionTitle = useContext(GridSectionTitleContext); + const section = useContext(GridSectionContext); const stableId = useRef(props.id || `__gi_${++gridItemOrderCounter}`).current; const orderRef = useRef(null); if (orderRef.current === null) orderRef.current = ++gridItemOrderCounter; useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); + registry.set(stableId, { props, section, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, sectionTitle, stableId]); + }, [props, registry, section, stableId]); return null; } - function GridSectionComponent({ children, title }: { children?: React.ReactNode; title?: string }) { - return {children}; - } - - function GridItemRenderer({ title, subtitle, content, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { - const isImageLikeSourceString = (value: string): boolean => { - const source = String(value || '').trim(); - if (!source) return false; - if ( - source.startsWith('http') || - source.startsWith('data:') || - source.startsWith('sc-asset:') || - source.startsWith('file://') || - source.startsWith('/') || - /^[a-zA-Z]:[\\/]/.test(source) || - source.startsWith('\\\\') - ) { - return true; - } - return /\.(svg|png|jpe?g|gif|webp|ico|tiff?)(\?.*)?$/i.test(source); - }; - - const getGridColor = (value: any): string | null => { - if (!value || typeof value !== 'object') return null; - const hasVisualSource = value.source !== undefined || value.value !== undefined || value.fileIcon !== undefined; - if (hasVisualSource) return null; - return resolveTintColor(value.color) || null; - }; - - const normalizeSourceString = (value: string): string => { - const source = String(value || '').trim(); - if (!source) return ''; - const resolved = resolveIconSrc(source); - return resolved || source; - }; - - const toRenderableContent = (value: any): any => { - if (!value) return null; - if (typeof value === 'string') { - const normalized = normalizeSourceString(value); - return normalized || null; - } - - if (typeof value !== 'object') return null; - - if (typeof value.fileIcon === 'string' && value.fileIcon.trim()) { - return { fileIcon: value.fileIcon.trim() }; - } - - if (value.source !== undefined) { - const sourceValue = value.source; - if (typeof sourceValue === 'string') { - const normalizedSource = normalizeSourceString(sourceValue); - if (!normalizedSource) return null; - const sourceTint = - value.tintColor - || (!isImageLikeSourceString(normalizedSource) ? value.color : undefined); - return { - source: normalizedSource, - tintColor: sourceTint, - mask: value.mask, - fallback: value.fallback, - }; - } - if (sourceValue && typeof sourceValue === 'object') { - return { - source: sourceValue, - tintColor: value.tintColor, - mask: value.mask, - fallback: value.fallback, - }; - } - } - - if (value.value !== undefined) { - const nestedValue = value.value; - if (typeof nestedValue === 'string') { - const normalizedNested = normalizeSourceString(nestedValue); - if (!normalizedNested) return null; - const nestedTint = - !isImageLikeSourceString(normalizedNested) ? value.color : undefined; - return nestedTint - ? { source: normalizedNested, tintColor: nestedTint } - : normalizedNested; - } - if (nestedValue && typeof nestedValue === 'object') { - if (typeof nestedValue.fileIcon === 'string' && nestedValue.fileIcon.trim()) { - return { fileIcon: nestedValue.fileIcon.trim() }; - } - - if (nestedValue.source !== undefined) { - return nestedValue; - } - - if (nestedValue.light !== undefined || nestedValue.dark !== undefined) { - return { source: nestedValue }; - } - } - } + function GridSectionComponent({ children, title, subtitle, columns, aspectRatio, fit, inset }: any) { + const stableId = useRef(`__gs_${++gridSectionOrderCounter}`).current; + const section = useMemo( + () => ({ id: stableId, title, subtitle, columns, aspectRatio, fit, inset }), + [aspectRatio, columns, fit, inset, stableId, subtitle, title], + ); - return null; - }; + return {children}; + } + const GridItemRenderer = React.memo(function GridItemRenderer({ + title, + subtitle, + content, + accessory, + isSelected, + dataIdx, + itemHeight, + fit, + inset, + onSelect, + onActivate, + onContextAction, + }: any) { const swatchColor = getGridColor(content); - const renderableContent = swatchColor ? null : toRenderableContent(content); + const renderableContent = swatchColor ? null : toRenderableGridContent(content, resolveIconSrc); + const accessoryIcon = accessory?.icon ? toRenderableGridContent(accessory.icon, resolveIconSrc) : null; + const accessoryTitle = typeof accessory?.tooltip === 'string' ? accessory.tooltip : undefined; + const contentFitClass = fit === 'fill' ? 'w-full h-full object-cover' : 'w-full h-full object-contain'; + const insetClass = + inset === 'zero' + ? 'p-0' + : inset === 'md' + ? 'p-3' + : inset === 'lg' + ? 'p-5' + : 'p-1.5'; return (
string) : 'border-[var(--launcher-card-border)] bg-[var(--launcher-card-bg)] hover:bg-[var(--launcher-card-hover-bg)]' }`} style={{ - height: '160px', + height: `${itemHeight || 160}px`, boxShadow: isSelected ? '0 0 0 2px rgba(var(--on-surface-rgb), 0.24), inset 0 0 0 1px rgba(var(--on-surface-rgb), 0.16)' : undefined, @@ -181,12 +238,12 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) onMouseMove={onSelect} onContextMenu={onContextAction} > -
+
{swatchColor ? (
) : renderableContent ? (
- {renderIcon(renderableContent, 'w-full h-full object-contain')} + {renderIcon(renderableContent, contentFitClass)}
) : (
@@ -194,15 +251,20 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
)}
- {title && ( + {(title || subtitle || accessoryIcon) && (
-

{title}

+ {accessoryIcon && ( +
+ {renderIcon(accessoryIcon, 'w-3 h-3 object-contain')} +
+ )} + {title &&

{title}

} {subtitle &&

{subtitle}

}
)}
); - } + }, areGridItemRendererPropsEqual); return { GridRegistryContext, diff --git a/src/renderer/src/raycast-api/grid-runtime-virtualization.ts b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts new file mode 100644 index 00000000..2daab2e2 --- /dev/null +++ b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts @@ -0,0 +1,306 @@ +/** + * Pure layout helpers for virtualized Grid rendering. + */ + +export const GRID_DEFAULT_COLUMNS = 5; +export const GRID_MIN_COLUMNS = 1; +export const GRID_MAX_COLUMNS = 8; +export const GRID_DEFAULT_ITEM_HEIGHT = 160; +export const GRID_ITEM_LABEL_HEIGHT = 34; +export const GRID_SECTION_HEADER_HEIGHT = 30; +export const GRID_ROW_GAP = 8; +export const GRID_GROUP_GAP = 8; +export const GRID_DEFAULT_OVERSCAN = GRID_DEFAULT_ITEM_HEIGHT * 2; + +export type GridFitValue = 'contain' | 'fill'; +export type GridInsetValue = 'zero' | 'sm' | 'md' | 'lg'; + +export interface GridSectionLayoutOptions { + id?: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + +export interface VirtualGridItemEntry { + item: { + id: string; + props: any; + section?: GridSectionLayoutOptions; + }; + globalIdx: number; +} + +export interface VirtualGridGroup { + key?: string; + title?: string; + subtitle?: string; + section?: GridSectionLayoutOptions; + items: VirtualGridItemEntry[]; +} + +export interface ResolvedGridSectionLayout { + columns: number; + aspectRatio?: string; + fit: GridFitValue; + inset: GridInsetValue; + itemHeight: number; +} + +export type VirtualGridRow = + | { + kind: 'section'; + key: string; + sectionKey: string; + title?: string; + subtitle?: string; + top: number; + height: number; + } + | { + kind: 'items'; + key: string; + sectionKey: string; + top: number; + height: number; + itemHeight: number; + items: VirtualGridItemEntry[]; + layout: ResolvedGridSectionLayout; + }; + +export interface VirtualGridLayout { + rows: VirtualGridRow[]; + totalHeight: number; + itemCount: number; + itemPositions: Array<{ top: number; height: number } | undefined>; + itemColumns: Array; +} + +export interface BuildVirtualGridLayoutOptions { + defaultColumns?: number; + itemSize?: string; + defaultAspectRatio?: string; + defaultFit?: string; + defaultInset?: string; + containerWidth?: number; +} + +export interface VisibleRowsOptions { + scrollTop: number; + viewportHeight: number; + overscan?: number; +} + +export interface ItemScrollOptions { + currentScrollTop: number; + viewportHeight: number; + scrollPadding?: number; +} + +export function normalizeGridColumns(columns?: number, fallback = GRID_DEFAULT_COLUMNS, itemSize?: string): number { + const sizeColumns = itemSize === 'small' ? 8 : itemSize === 'large' ? 3 : undefined; + const explicitColumns = typeof columns === 'number' && Number.isFinite(columns) ? columns : undefined; + const candidate = explicitColumns ?? sizeColumns ?? fallback; + const normalized = Number.isFinite(candidate) ? Math.floor(candidate) : GRID_DEFAULT_COLUMNS; + return Math.max(GRID_MIN_COLUMNS, Math.min(GRID_MAX_COLUMNS, normalized)); +} + +export function normalizeGridFit(value?: string): GridFitValue { + return value === 'fill' ? 'fill' : 'contain'; +} + +export function normalizeGridInset(value?: string): GridInsetValue { + if (value === 'zero' || value === 'none') return 'zero'; + if (value === 'sm' || value === 'small') return 'sm'; + if (value === 'md' || value === 'medium') return 'md'; + if (value === 'lg' || value === 'large') return 'lg'; + return 'sm'; +} + +export function normalizeGridAspectRatio(value?: string): string | undefined { + if (!value) return undefined; + const ratio = parseGridAspectRatio(value); + return ratio > 0 ? value : undefined; +} + +export function parseGridAspectRatio(value?: string): number { + if (!value) return 0; + if (value.includes('/')) { + const [width, height] = value.split('/').map((part) => Number(part)); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width / height; + } + return 0; + } + + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +export function getGridItemHeight(columns: number, containerWidth?: number, aspectRatio?: string): number { + const ratio = parseGridAspectRatio(aspectRatio); + if (!ratio || !containerWidth || containerWidth <= 0) return GRID_DEFAULT_ITEM_HEIGHT; + + const safeColumns = normalizeGridColumns(columns); + const totalGap = GRID_ROW_GAP * Math.max(0, safeColumns - 1); + const itemWidth = Math.max(1, (containerWidth - totalGap) / safeColumns); + return Math.max(96, Math.ceil(itemWidth / ratio + GRID_ITEM_LABEL_HEIGHT)); +} + +export function resolveGridSectionLayout( + section: GridSectionLayoutOptions | undefined, + options: BuildVirtualGridLayoutOptions, +): ResolvedGridSectionLayout { + const columns = normalizeGridColumns(section?.columns, normalizeGridColumns(options.defaultColumns, GRID_DEFAULT_COLUMNS, options.itemSize)); + const aspectRatio = normalizeGridAspectRatio(section?.aspectRatio ?? options.defaultAspectRatio); + const fit = normalizeGridFit(section?.fit ?? options.defaultFit); + const inset = normalizeGridInset(section?.inset ?? options.defaultInset); + const itemHeight = getGridItemHeight(columns, options.containerWidth, aspectRatio); + + return { columns, aspectRatio, fit, inset, itemHeight }; +} + +export function buildVirtualGridLayout( + groups: VirtualGridGroup[], + options: BuildVirtualGridLayoutOptions = {}, +): VirtualGridLayout { + const rows: VirtualGridRow[] = []; + const itemPositions: Array<{ top: number; height: number } | undefined> = []; + const itemColumns: Array = []; + let top = 0; + let itemCount = 0; + + groups.forEach((group, groupIndex) => { + const section = group.section; + const sectionKey = group.key || section?.id || group.title || `group-${groupIndex}`; + const title = group.title ?? section?.title; + const subtitle = group.subtitle ?? section?.subtitle; + + if (title) { + rows.push({ + kind: 'section', + key: `${sectionKey}:header`, + sectionKey, + title, + subtitle, + top, + height: GRID_SECTION_HEADER_HEIGHT, + }); + top += GRID_SECTION_HEADER_HEIGHT; + } + + const layout = resolveGridSectionLayout(section, options); + for (let start = 0; start < group.items.length; start += layout.columns) { + const rowItems = group.items.slice(start, start + layout.columns); + rows.push({ + kind: 'items', + key: `${sectionKey}:items:${start}`, + sectionKey, + top, + height: layout.itemHeight, + itemHeight: layout.itemHeight, + items: rowItems, + layout, + }); + + for (const entry of rowItems) { + itemPositions[entry.globalIdx] = { top, height: layout.itemHeight }; + itemColumns[entry.globalIdx] = layout.columns; + } + + itemCount += rowItems.length; + top += layout.itemHeight; + if (start + layout.columns < group.items.length) top += GRID_ROW_GAP; + } + + if (title || group.items.length > 0) top += GRID_GROUP_GAP; + }); + + return { + rows, + totalHeight: top, + itemCount, + itemPositions, + itemColumns, + }; +} + +export function getVisibleVirtualRows(rows: VirtualGridRow[], options: VisibleRowsOptions): VirtualGridRow[] { + const overscan = options.overscan ?? GRID_DEFAULT_OVERSCAN; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const start = Math.max(0, options.scrollTop - overscan); + const end = options.scrollTop + viewportHeight + overscan; + + const firstVisibleIndex = findFirstRowWithBottomAtOrAfter(rows, start); + const endIndex = findFirstRowWithTopAfter(rows, end); + + return rows.slice(firstVisibleIndex, endIndex); +} + +function findFirstRowWithBottomAtOrAfter(rows: VirtualGridRow[], start: number): number { + let low = 0; + let high = rows.length; + + while (low < high) { + const mid = low + Math.floor((high - low) / 2); + const row = rows[mid]; + if (row.top + row.height < start) low = mid + 1; + else high = mid; + } + + return low; +} + +function findFirstRowWithTopAfter(rows: VirtualGridRow[], end: number): number { + let low = 0; + let high = rows.length; + + while (low < high) { + const mid = low + Math.floor((high - low) / 2); + if (rows[mid].top <= end) low = mid + 1; + else high = mid; + } + + return low; +} + +export function getScrollTopForItemIndex( + layout: Pick, + itemIndex: number, + options: ItemScrollOptions, +): number { + const position = layout.itemPositions[itemIndex]; + if (!position) return options.currentScrollTop; + + const scrollPadding = options.scrollPadding ?? GRID_ROW_GAP; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const currentTop = Math.max(0, options.currentScrollTop); + const currentBottom = currentTop + viewportHeight; + const itemTop = position.top; + const itemBottom = position.top + position.height; + + if (itemTop < currentTop + scrollPadding) { + return Math.max(0, itemTop - scrollPadding); + } + + if (itemBottom > currentBottom - scrollPadding) { + return Math.max(0, itemBottom - viewportHeight + scrollPadding); + } + + return currentTop; +} + +export function getColumnsForItemIndex( + layout: Pick, + itemIndex: number, + fallbackColumns = GRID_DEFAULT_COLUMNS, +): number { + return normalizeGridColumns(layout.itemColumns[itemIndex], fallbackColumns); +} + +export function countVirtualizedRowItems(rows: VirtualGridRow[]): number { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} diff --git a/src/renderer/src/raycast-api/grid-runtime.tsx b/src/renderer/src/raycast-api/grid-runtime.tsx index 0b30a849..58a4d733 100644 --- a/src/renderer/src/raycast-api/grid-runtime.tsx +++ b/src/renderer/src/raycast-api/grid-runtime.tsx @@ -10,6 +10,14 @@ import type { ExtractedAction } from './action-runtime'; import { transliterateForSearch } from '../utils/transliterate'; import { createGridItemsRuntime } from './grid-runtime-items'; import { groupGridItems, useGridRegistry } from './grid-runtime-hooks'; +import { + GRID_DEFAULT_COLUMNS, + buildVirtualGridLayout, + getColumnsForItemIndex, + getScrollTopForItemIndex, + getVisibleVirtualRows, + normalizeGridColumns, +} from './grid-runtime-virtualization'; import { useI18n } from '../i18n'; interface GridRuntimeDeps { @@ -53,6 +61,10 @@ export function createGridRuntime(deps: GridRuntimeDeps) { function GridComponent({ children, columns, + itemSize, + aspectRatio, + fit, + inset, isLoading, searchBarPlaceholder, onSearchTextChange, @@ -73,9 +85,37 @@ export function createGridRuntime(deps: GridRuntimeDeps) { const gridRef = useRef(null); const { pop } = useNavigation(); - const cols = columns || 5; + const rootColumns = useMemo(() => normalizeGridColumns(columns, GRID_DEFAULT_COLUMNS, itemSize), [columns, itemSize]); + const [gridViewport, setGridViewport] = useState({ scrollTop: 0, viewportHeight: 0, containerWidth: 0 }); const { registryAPI, allItems } = useGridRegistry(); + const measureGridViewport = useCallback(() => { + const node = gridRef.current; + if (!node) return; + + const nextViewport = { + scrollTop: node.scrollTop, + viewportHeight: node.clientHeight, + containerWidth: Math.max(0, node.clientWidth - 16), + }; + + setGridViewport((current) => ( + Math.abs(current.scrollTop - nextViewport.scrollTop) < 1 + && current.viewportHeight === nextViewport.viewportHeight + && current.containerWidth === nextViewport.containerWidth + ? current + : nextViewport + )); + }, []); + + const handleGridScroll = useCallback((event: React.UIEvent) => { + const node = event.currentTarget; + setGridViewport((current) => { + if (Math.abs(current.scrollTop - node.scrollTop) < 1) return current; + return { ...current, scrollTop: node.scrollTop }; + }); + }, []); + useEffect(() => { if (controlledSearch === undefined) return; setInternalSearch(controlledSearch); @@ -109,6 +149,26 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }); }, [allItems, filtering, internalSearch, onSearchTextChange]); + const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); + const virtualLayout = useMemo( + () => buildVirtualGridLayout(groupedItems, { + defaultColumns: rootColumns, + itemSize, + defaultAspectRatio: aspectRatio, + defaultFit: fit, + defaultInset: inset, + containerWidth: gridViewport.containerWidth, + }), + [aspectRatio, fit, gridViewport.containerWidth, groupedItems, inset, itemSize, rootColumns], + ); + const visibleRows = useMemo( + () => getVisibleVirtualRows(virtualLayout.rows, { + scrollTop: gridViewport.scrollTop, + viewportHeight: gridViewport.viewportHeight, + }), + [gridViewport.scrollTop, gridViewport.viewportHeight, virtualLayout.rows], + ); + const debounceRef = useRef | null>(null); const handleSearchChange = useCallback( (value: string) => { @@ -171,14 +231,16 @@ export function createGridRuntime(deps: GridRuntimeDeps) { if (event.key === 'ArrowRight') setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft') setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + cols, filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - cols, 0)); - else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); + else if (event.key === 'ArrowDown') { + setSelectedIdx((value) => Math.min(value + getColumnsForItemIndex(virtualLayout, value, rootColumns), filteredItems.length - 1)); + } else if (event.key === 'ArrowUp') { + setSelectedIdx((value) => Math.max(value - getColumnsForItemIndex(virtualLayout, value, rootColumns), 0)); + } else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; event.preventDefault(); }, - [cols, filteredItems.length, isMetaK, matchesShortcut, primaryAction, selectedActions, showActions], + [filteredItems.length, isMetaK, matchesShortcut, primaryAction, rootColumns, selectedActions, showActions, virtualLayout], ); useEffect(() => { @@ -192,21 +254,43 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }, [filteredItems.length, selectedIdx]); useEffect(() => { - gridRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }, [selectedIdx]); + const node = gridRef.current; + if (!node || filteredItems.length === 0) return; + + const nextScrollTop = getScrollTopForItemIndex(virtualLayout, selectedIdx, { + currentScrollTop: node.scrollTop, + viewportHeight: node.clientHeight || gridViewport.viewportHeight, + }); + if (Math.abs(nextScrollTop - node.scrollTop) < 1) return; + + node.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + requestAnimationFrame(measureGridViewport); + }, [filteredItems.length, gridViewport.viewportHeight, measureGridViewport, selectedIdx, virtualLayout]); useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { + measureGridViewport(); + const node = gridRef.current; + if (!node) return; + + const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measureGridViewport) : null; + resizeObserver?.observe(node); + window.addEventListener('resize', measureGridViewport); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', measureGridViewport); + }; + }, [measureGridViewport]); + useEffect(() => { if (onSelectionChange && filteredItems[selectedIdx]) { onSelectionChange(filteredItems[selectedIdx]?.props?.id || null); } }, [filteredItems, onSelectionChange, selectedIdx]); - const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); - return (
@@ -229,40 +313,64 @@ export function createGridRuntime(deps: GridRuntimeDeps) { {searchBarAccessory &&
{searchBarAccessory}
}
-
+
{isLoading && filteredItems.length === 0 ? (

{t('common.loading')}

) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}
} -
- {group.items.map(({ item, globalIdx }) => ( - setSelectedIdx(globalIdx)} - onActivate={() => { - setSelectedIdx(globalIdx); - inputRef.current?.focus(); - }} - onContextAction={(event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setSelectedIdx(globalIdx); - setShowActions(true); - }} - /> - ))} -
-
- )) +
+ {visibleRows.map((row) => ( + row.kind === 'section' ? ( +
+ {row.title} + {row.subtitle && ( + {row.subtitle} + )} +
+ ) : ( +
+ {row.items.map(({ item, globalIdx }) => ( + setSelectedIdx(globalIdx)} + onActivate={() => { + setSelectedIdx(globalIdx); + inputRef.current?.focus(); + }} + onContextAction={(event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedIdx(globalIdx); + setShowActions(true); + }} + /> + ))} +
+ ) + ))} +
)}
@@ -300,7 +408,7 @@ export function createGridRuntime(deps: GridRuntimeDeps) { ); } - const GridInset = { Small: 'small', Medium: 'medium', Large: 'large' } as const; + const GridInset = { Zero: 'zero', Small: 'sm', Medium: 'md', Large: 'lg' } as const; const GridItemSize = { Small: 'small', Medium: 'medium', Large: 'large' } as const; const GridFit = { Contain: 'contain', Fill: 'fill' } as const; diff --git a/src/renderer/src/raycast-api/hooks/storage-scope.ts b/src/renderer/src/raycast-api/hooks/storage-scope.ts index 2898c8eb..406a3c6d 100644 --- a/src/renderer/src/raycast-api/hooks/storage-scope.ts +++ b/src/renderer/src/raycast-api/hooks/storage-scope.ts @@ -44,6 +44,13 @@ export function getScopedLocalStorageKeys(key: string): { scopedKey: string; leg }; } +export function getScopedFrecencyStorageKeys(namespace: string): { scopedKey: string; legacyKeys: string[] } { + return { + scopedKey: `sc-frecency:${getExtensionStorageScope()}:${namespace}`, + legacyKeys: [`sc-frecency-${namespace}`], + }; +} + export function readScopedJsonState( scopedKey: string, legacyKeys: string[], diff --git a/src/renderer/src/raycast-api/hooks/use-ai.ts b/src/renderer/src/raycast-api/hooks/use-ai.ts index 1bf07c76..fd8a60ab 100644 --- a/src/renderer/src/raycast-api/hooks/use-ai.ts +++ b/src/renderer/src/raycast-api/hooks/use-ai.ts @@ -7,6 +7,113 @@ import { useCallback, useEffect, useRef, useState } from 'react'; type AICreativity = 'none' | 'low' | 'medium' | 'high' | 'maximum' | number; +export const RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS = 32; + +interface RaycastAIStreamDataRef { + current: string; +} + +export interface RaycastAIStreamBatcherOptions { + dataRef: RaycastAIStreamDataRef; + setVisibleData: (data: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface RaycastAIStreamBatcher { + appendChunk: (chunk: string) => void; + cancelPendingFlush: () => void; + flush: () => void; + reset: (data?: string) => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createRaycastAIStreamBatcher({ + dataRef, + setVisibleData, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS, +}: RaycastAIStreamBatcherOptions): RaycastAIStreamBatcher { + let visibleData = dataRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleData = () => { + const nextData = dataRef.current; + if (nextData === visibleData) return; + visibleData = nextData; + setVisibleData(nextData); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const flush = () => { + cancelPendingFlush(); + publishVisibleData(); + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleData(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + dataRef.current += chunk; + scheduleFlush(); + }, + cancelPendingFlush, + flush, + reset(data = '') { + cancelPendingFlush(); + dataRef.current = data; + visibleData = data; + setVisibleData(data); + }, + }; +} + export function useAI( prompt: string, options?: { @@ -24,17 +131,27 @@ export function useAI( const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); const abortRef = useRef(null); + const dataRef = useRef(''); + const streamBatcherRef = useRef(null); const promptRef = useRef(prompt); const optionsRef = useRef(options); promptRef.current = prompt; optionsRef.current = options; + if (!streamBatcherRef.current) { + streamBatcherRef.current = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData: setData, + }); + } + const shouldExecute = options?.execute !== false; const stream = options?.stream !== false; const run = useCallback(() => { if (!promptRef.current) return; const opts = optionsRef.current; + const streamBatcher = streamBatcherRef.current; abortRef.current?.abort(); const controller = new AbortController(); @@ -42,7 +159,7 @@ export function useAI( setIsLoading(true); setError(undefined); - setData(''); + streamBatcher?.reset(''); opts?.onWillExecute?.([promptRef.current]); @@ -64,20 +181,22 @@ export function useAI( if (stream) { sp.on('data', (chunk: string) => { if (!controller.signal.aborted) { - setData((prev) => prev + chunk); + streamBatcher?.appendChunk(chunk); } }); } sp.then((fullText: string) => { if (!controller.signal.aborted) { - if (!stream) setData(fullText); + dataRef.current = fullText; + streamBatcher?.flush(); setIsLoading(false); opts?.onData?.(fullText); } }).catch((err: any) => { if (!controller.signal.aborted) { const e = err instanceof Error ? err : new Error(err?.message || 'AI request failed'); + streamBatcher?.flush(); setError(e); setIsLoading(false); opts?.onError?.(e); @@ -91,6 +210,7 @@ export function useAI( } return () => { abortRef.current?.abort(); + streamBatcherRef.current?.cancelPendingFlush(); }; }, [shouldExecute, run]); diff --git a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts index 7d1f4c4e..4c342832 100644 --- a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts @@ -47,13 +47,9 @@ export function useCachedPromise( const [isPaginated, setIsPaginated] = useState(false); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - + const latestRunIdRef = useRef(0); + const abortControllerRef = useRef(null); + const abortableRefRef = useRef | undefined>(undefined); const fnRef = useRef(fn); const argsRef = useRef(args || []); const optionsRef = useRef(options); @@ -63,30 +59,69 @@ export function useCachedPromise( optionsRef.current = options; runtimeCtxRef.current = snapshotExtensionContext(); + const clearAbortController = useCallback((controller: AbortController) => { + const abortableRef = abortableRefRef.current; + if (abortableRef?.current === controller) { + abortableRef.current = null; + } + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + if (abortableRefRef.current === abortableRef) { + abortableRefRef.current = undefined; + } + } + }, []); + + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + clearAbortController(controller); + }, [clearAbortController]); + + const prepareAbortController = useCallback((abortable?: React.MutableRefObject) => { + abortCurrentRun(); + if (!abortable) return null; + + const controller = new AbortController(); + abortControllerRef.current = controller; + abortableRefRef.current = abortable; + abortable.current = controller; + return controller; + }, [abortCurrentRun]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const fetchPage = useCallback(async (pageNum: number, currentCursor?: string) => { const opts = optionsRef.current; if (opts?.execute === false || !mountedRef.current) return; + const runController = prepareAbortController(opts?.abortable); + const runId = ++latestRunIdRef.current; + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => latestRunIdRef.current === runId && (!runController || abortControllerRef.current === runController); + setIsLoading(true); setError(undefined); - if (opts?.abortable) { - const controller = new AbortController(); - opts.abortable.current = controller; - } - - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onWillExecute?.(argsRef.current); }); try { - const outerResult = withExtensionContext(runtimeCtxRef.current, () => fnRef.current(...argsRef.current)); + const outerResult = withExtensionContext(runCtx, () => fnRef.current(...argsRef.current)); if (typeof outerResult === 'function') { setIsPaginated(true); const paginationOptions = { page: pageNum, cursor: currentCursor, lastItem: undefined }; - const innerResult = await withExtensionContext(runtimeCtxRef.current, () => outerResult(paginationOptions)); - if (!mountedRef.current) return; + const innerResult = await withExtensionContext(runCtx, () => outerResult(paginationOptions)); + if (!mountedRef.current || !isCurrentRun()) return; if (innerResult && typeof innerResult === 'object' && 'data' in innerResult) { const { data: pageData, hasMore: more, cursor: nextCursor } = innerResult; @@ -96,14 +131,14 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; }); } - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.((innerResult as any).data); }); } else { @@ -112,7 +147,7 @@ export function useCachedPromise( } } else { const result = await outerResult; - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; if (result && typeof result === 'object' && 'data' in result && 'hasMore' in result) { setIsPaginated(true); @@ -123,35 +158,40 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; }); } - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.(pageData as T); }); } else { setAccumulatedData(result as any); setHasMore(false); - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.(result as T); }); } } } catch (err) { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onError?.(e); }); } finally { - if (mountedRef.current) setIsLoading(false); + if (mountedRef.current && isCurrentRun()) { + setIsLoading(false); + } + if (runController) { + clearAbortController(runController); + } } - }, []); + }, [prepareAbortController, clearAbortController]); const argsKey = JSON.stringify(args || []); useEffect(() => { diff --git a/src/renderer/src/raycast-api/hooks/use-fetch.ts b/src/renderer/src/raycast-api/hooks/use-fetch.ts index 95f882d0..ac9a0022 100644 --- a/src/renderer/src/raycast-api/hooks/use-fetch.ts +++ b/src/renderer/src/raycast-api/hooks/use-fetch.ts @@ -46,10 +46,13 @@ export function useFetch( const [error, setError] = useState(undefined); const mountedRef = useRef(true); + const runIdRef = useRef(0); + const lastInitialRequestKeyRef = useRef(undefined); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + runIdRef.current += 1; }; }, []); @@ -58,17 +61,26 @@ export function useFetch( urlRef.current = url; optionsRef.current = options; - const fetchData = useCallback(async (pageNum: number, currentCursor?: string) => { + const resolveUrl = useCallback(( + requestUrl: typeof url, + pageNum: number, + currentCursor?: string, + ) => typeof requestUrl === 'function' + ? requestUrl({ page: pageNum, cursor: currentCursor, lastItem: undefined }) + : requestUrl, []); + + const fetchData = useCallback(async (pageNum: number, currentCursor?: string, resolvedUrlOverride?: string) => { const opts = optionsRef.current; if (opts?.execute === false || !mountedRef.current) return; + const runId = ++runIdRef.current; + const isCurrentRun = () => mountedRef.current && runIdRef.current === runId; + setIsLoading(true); setError(undefined); try { - const resolvedUrl = typeof urlRef.current === 'function' - ? urlRef.current({ page: pageNum, cursor: currentCursor, lastItem: undefined }) - : urlRef.current; + const resolvedUrl = resolvedUrlOverride ?? resolveUrl(urlRef.current, pageNum, currentCursor); const ipcRes = await window.electron.httpRequest({ url: resolvedUrl, @@ -76,6 +88,7 @@ export function useFetch( headers: opts?.headers, body: normalizeRequestBody(opts?.body) as string | undefined, }); + if (!isCurrentRun()) return; const res = { ok: ipcRes.status >= 200 && ipcRes.status < 300, @@ -90,9 +103,10 @@ export function useFetch( if (!res.ok) throw new Error(`HTTP ${res.status}`); const parsed = opts?.parseResponse ? await opts.parseResponse(res) : await res.json(); - if (!mountedRef.current) return; + if (!isCurrentRun()) return; const mapped = opts?.mapResult ? opts.mapResult(parsed) : parsed; + if (!isCurrentRun()) return; if (mapped && typeof mapped === 'object' && 'data' in mapped) { const paginatedResult = mapped as { data: T; hasMore?: boolean; cursor?: string }; setHasMore(paginatedResult.hasMore ?? false); @@ -112,16 +126,15 @@ export function useFetch( opts?.onData?.(mapped as T); } } catch (err) { - if (!mountedRef.current) return; + if (!isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - if (mountedRef.current) setIsLoading(false); + if (isCurrentRun()) setIsLoading(false); } - }, []); + }, [resolveUrl]); - const urlString = typeof url === 'string' ? url : 'function'; const optionsKey = useMemo(() => { try { return JSON.stringify({ @@ -137,16 +150,32 @@ export function useFetch( useEffect(() => { if (options?.execute === false) { + runIdRef.current += 1; + lastInitialRequestKeyRef.current = undefined; setIsLoading(false); setError(undefined); setAllData(options?.initialData); return; } + + let initialResolvedUrl: string | undefined; + let initialUrlKey: string; + try { + initialResolvedUrl = resolveUrl(url, 0, undefined); + initialUrlKey = initialResolvedUrl; + } catch (err) { + initialUrlKey = `error:${err instanceof Error ? err.message : String(err)}`; + } + + const initialRequestKey = `${optionsKey}\n${initialUrlKey}`; + if (lastInitialRequestKeyRef.current === initialRequestKey) return; + lastInitialRequestKeyRef.current = initialRequestKey; + setPage(0); setCursor(undefined); setAllData(options?.initialData); - fetchData(0, undefined); - }, [fetchData, urlString, optionsKey]); + fetchData(0, undefined, initialResolvedUrl); + }, [fetchData, url, optionsKey]); const revalidate = useCallback(() => { setPage(0); diff --git a/src/renderer/src/raycast-api/hooks/use-form.ts b/src/renderer/src/raycast-api/hooks/use-form.ts index a1f8d88f..94a5dc8d 100644 --- a/src/renderer/src/raycast-api/hooks/use-form.ts +++ b/src/renderer/src/raycast-api/hooks/use-form.ts @@ -4,6 +4,7 @@ */ import { useCallback, useMemo, useState } from 'react'; +import { clearFormFieldError, setFormFieldError, type FormErrorMap } from '../form-runtime-state'; export enum FormValidation { Required = 'required', @@ -23,25 +24,21 @@ export function useForm = Record>(opt focus: (key: keyof T) => void; } { const [values, setValues] = useState((options.initialValues || {}) as T); - const [errors, setErrors] = useState>>({}); + const [errors, setErrors] = useState({}); const setValue = useCallback((key: keyof T, value: any) => { setValues((prev) => ({ ...prev, [key]: value })); - setErrors((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); + setErrors((prev) => clearFormFieldError(prev, key as string)); }, []); const setValidationError = useCallback((key: keyof T, error: string) => { - setErrors((prev) => ({ ...prev, [key]: error })); + setErrors((prev) => setFormFieldError(prev, key as string, error)); }, []); const validate = useCallback((): boolean => { if (!options.validation) return true; - const newErrors: Partial> = {}; + const newErrors: FormErrorMap = {}; let valid = true; for (const key of Object.keys(options.validation) as (keyof T)[]) { @@ -62,7 +59,7 @@ export function useForm = Record>(opt } if (error) { - newErrors[key] = error; + newErrors[key as string] = error; valid = false; } } @@ -99,7 +96,7 @@ export function useForm = Record>(opt id: key as string, value: values[key as keyof T], onChange: (v: any) => setValue(key as keyof T, v), - error: errors[key as keyof T], + error: errors[key as string], onBlur: () => { const rule = options.validation?.[key as keyof T]; if (!rule) return; @@ -121,7 +118,7 @@ export function useForm = Record>(opt } if (err) { - setErrors((prev) => ({ ...prev, [key]: err })); + setErrors((prev) => setFormFieldError(prev, key as string, err)); } }, }; diff --git a/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts b/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts index 5cd648bb..dd2a3883 100644 --- a/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts +++ b/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts @@ -4,14 +4,20 @@ */ import { useCallback, useMemo, useState } from 'react'; +import { getScopedFrecencyStorageKeys, readScopedJsonState } from './storage-scope'; interface FrecencyEntry { count: number; lastVisited: number; } -function computeFrecencyScore(entry: FrecencyEntry): number { - const ageHours = (Date.now() - entry.lastVisited) / (1000 * 60 * 60); +function getDefaultFrecencyKey(item: unknown): string { + const itemId = (item as { id?: unknown } | null | undefined)?.id; + return itemId == null ? String(item) : String(itemId); +} + +function computeFrecencyScore(entry: FrecencyEntry, now: number): number { + const ageHours = (now - entry.lastVisited) / (1000 * 60 * 60); const decay = Math.pow(0.5, ageHours / 72); return entry.count * decay; } @@ -29,25 +35,21 @@ export function useFrecencySorting( resetRanking: (item: T) => Promise; } { const ns = options?.namespace || 'default'; - const storageKey = `sc-frecency-${ns}`; - const getKey = options?.key || ((item: any) => item?.id ?? String(item)); + const { scopedKey, legacyKeys } = getScopedFrecencyStorageKeys(ns); + const getKey = options?.key || getDefaultFrecencyKey; const [frecencyMap, setFrecencyMap] = useState>(() => { - try { - const stored = localStorage.getItem(storageKey); - return stored ? JSON.parse(stored) : {}; - } catch { - return {}; - } + const stored = readScopedJsonState>(scopedKey, legacyKeys); + return stored === undefined ? {} : stored; }); const persistMap = useCallback((map: Record) => { try { - localStorage.setItem(storageKey, JSON.stringify(map)); + localStorage.setItem(scopedKey, JSON.stringify(map)); } catch { // best-effort } - }, [storageKey]); + }, [scopedKey]); const sortedData = useMemo(() => { if (!data) return []; @@ -60,13 +62,22 @@ export function useFrecencySorting( } const items = [...data]; + let scoreNow: number | undefined; + const getScoreNow = () => { + scoreNow ??= Date.now(); + return scoreNow; + }; + items.sort((a, b) => { const keyA = getKey(a); const keyB = getKey(b); const entryA = frecencyMap[keyA]; const entryB = frecencyMap[keyB]; - if (entryA && entryB) return computeFrecencyScore(entryB) - computeFrecencyScore(entryA); + if (entryA && entryB) { + const now = getScoreNow(); + return computeFrecencyScore(entryB, now) - computeFrecencyScore(entryA, now); + } if (entryA && !entryB) return -1; if (!entryA && entryB) return 1; if (options?.sortUnvisited) return options.sortUnvisited(a, b); diff --git a/src/renderer/src/raycast-api/hooks/use-local-storage.ts b/src/renderer/src/raycast-api/hooks/use-local-storage.ts index 98370518..e3ca8900 100644 --- a/src/renderer/src/raycast-api/hooks/use-local-storage.ts +++ b/src/renderer/src/raycast-api/hooks/use-local-storage.ts @@ -5,6 +5,7 @@ import { useCallback, useState } from 'react'; import { emitExtensionStorageChanged } from '../storage-events'; +import { getCurrentScopedExtensionContext } from '../context-scope-runtime'; import { getScopedLocalStorageKeys, readScopedJsonState } from './storage-scope'; export function useLocalStorage( @@ -17,6 +18,10 @@ export function useLocalStorage( isLoading: boolean; } { const { scopedKey, legacyKeys } = getScopedLocalStorageKeys(key); + const currentContext = getCurrentScopedExtensionContext(); + const storageEventExtensionName = currentContext.extensionName; + const storageEventCommandName = currentContext.commandName; + const storageEventCommandMode = currentContext.commandMode; const [value, setValueState] = useState(() => { return readScopedJsonState(scopedKey, legacyKeys, initialValue); }); @@ -29,8 +34,12 @@ export function useLocalStorage( } catch { // best-effort } - emitExtensionStorageChanged(); - }, [scopedKey]); + emitExtensionStorageChanged({ + extensionName: storageEventExtensionName, + commandName: storageEventCommandName, + commandMode: storageEventCommandMode, + }); + }, [scopedKey, storageEventCommandMode, storageEventCommandName, storageEventExtensionName]); const removeValue = useCallback(async () => { setValueState(undefined); @@ -42,8 +51,12 @@ export function useLocalStorage( } catch { // best-effort } - emitExtensionStorageChanged(); - }, [legacyKeys, scopedKey]); + emitExtensionStorageChanged({ + extensionName: storageEventExtensionName, + commandName: storageEventCommandName, + commandMode: storageEventCommandMode, + }); + }, [legacyKeys, scopedKey, storageEventCommandMode, storageEventCommandName, storageEventExtensionName]); return { value, setValue, removeValue, isLoading }; } diff --git a/src/renderer/src/raycast-api/hooks/use-promise.ts b/src/renderer/src/raycast-api/hooks/use-promise.ts index 5127e284..04f466a5 100644 --- a/src/renderer/src/raycast-api/hooks/use-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-promise.ts @@ -49,51 +49,100 @@ export function usePromise( const [error, setError] = useState(undefined); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + const latestRunIdRef = useRef(0); + const abortControllerRef = useRef(null); + const abortableRefRef = useRef | undefined>(undefined); const stableArgs = useStableArgs(args || []); const fnRef = useRef(fn); const argsRef = useRef(stableArgs); + const optionsRef = useRef(options); const runtimeCtxRef = useRef(snapshotExtensionContext()); fnRef.current = fn; argsRef.current = stableArgs; + optionsRef.current = options; runtimeCtxRef.current = snapshotExtensionContext(); + const clearAbortController = useCallback((controller: AbortController) => { + const abortableRef = abortableRefRef.current; + if (abortableRef?.current === controller) { + abortableRef.current = null; + } + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + if (abortableRefRef.current === abortableRef) { + abortableRefRef.current = undefined; + } + } + }, []); + + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + clearAbortController(controller); + }, [clearAbortController]); + + const prepareAbortController = useCallback((abortable?: React.MutableRefObject) => { + abortCurrentRun(); + if (!abortable) return null; + + const controller = new AbortController(); + abortControllerRef.current = controller; + abortableRefRef.current = abortable; + abortable.current = controller; + return controller; + }, [abortCurrentRun]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const execute = useCallback(() => { - if (options?.execute === false || !mountedRef.current) return; + const opts = optionsRef.current; + if (opts?.execute === false || !mountedRef.current) return; + + const runController = prepareAbortController(opts?.abortable); + const runId = ++latestRunIdRef.current; + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => latestRunIdRef.current === runId && (!runController || abortControllerRef.current === runController); setIsLoading(true); setError(undefined); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onWillExecute?.(argsRef.current); + withExtensionContext(runCtx, () => { + opts?.onWillExecute?.(argsRef.current); }); Promise.resolve() - .then(() => withExtensionContext(runtimeCtxRef.current, () => fnRef.current(...argsRef.current))) + .then(() => withExtensionContext(runCtx, () => fnRef.current(...argsRef.current))) .then((result) => { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; setData(result); setIsLoading(false); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onData?.(result); + withExtensionContext(runCtx, () => { + opts?.onData?.(result); }); }) .catch((err) => { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); setIsLoading(false); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onError?.(e); + withExtensionContext(runCtx, () => { + opts?.onError?.(e); }); + }) + .finally(() => { + if (runController) { + clearAbortController(runController); + } }); - }, [options?.execute]); + }, [options?.execute, prepareAbortController, clearAbortController]); useEffect(() => { execute(); diff --git a/src/renderer/src/raycast-api/hooks/use-stream-json.ts b/src/renderer/src/raycast-api/hooks/use-stream-json.ts index 6983704e..e879d69d 100644 --- a/src/renderer/src/raycast-api/hooks/use-stream-json.ts +++ b/src/renderer/src/raycast-api/hooks/use-stream-json.ts @@ -5,21 +5,62 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +type UseStreamJSONOptions = RequestInit & { + filter?: (item: T) => boolean; + transform?: (item: any) => T; + dataPath?: string | RegExp; + pageSize?: number; + initialData?: T[]; + keepPreviousData?: boolean; + execute?: boolean; + onError?: (error: Error) => void; + onData?: (data: T) => void; + onWillExecute?: (args: [string, RequestInit]) => void; + failureToastOptions?: any; +}; + +function abortWithSignalReason(controller: AbortController, signal: AbortSignal) { + if (controller.signal.aborted) return; + try { + controller.abort(signal.reason); + } catch { + controller.abort(); + } +} + +function composeAbortSignal(lifecycleSignal: AbortSignal, callerSignal?: AbortSignal | null) { + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + const observedSignals = new Set(); + + const observe = (signal?: AbortSignal | null) => { + if (!signal || observedSignals.has(signal)) return; + observedSignals.add(signal); + + if (signal.aborted) { + abortWithSignalReason(controller, signal); + return; + } + + const abort = () => abortWithSignalReason(controller, signal); + signal.addEventListener('abort', abort, { once: true }); + cleanups.push(() => signal.removeEventListener('abort', abort)); + }; + + observe(lifecycleSignal); + observe(callerSignal); + + return { + signal: controller.signal, + cleanup: () => { + for (const cleanup of cleanups) cleanup(); + }, + }; +} + export function useStreamJSON( url: string | Request, - options?: RequestInit & { - filter?: (item: T) => boolean; - transform?: (item: any) => T; - dataPath?: string | RegExp; - pageSize?: number; - initialData?: T[]; - keepPreviousData?: boolean; - execute?: boolean; - onError?: (error: Error) => void; - onData?: (data: T) => void; - onWillExecute?: (args: [string, RequestInit]) => void; - failureToastOptions?: any; - } + options?: UseStreamJSONOptions ) { const pageSize = options?.pageSize ?? 20; const [allItems, setAllItems] = useState(options?.initialData || []); @@ -27,22 +68,59 @@ export function useStreamJSON( const [error, setError] = useState(undefined); const [displayCount, setDisplayCount] = useState(pageSize); + const mountedRef = useRef(true); + const runIdRef = useRef(0); + const abortControllerRef = useRef(null); const optionsRef = useRef(options); optionsRef.current = options; + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + abortControllerRef.current = null; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + runIdRef.current += 1; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const fetchAndParse = useCallback(async () => { const opts = optionsRef.current; - if (opts?.execute === false) return; + const runId = runIdRef.current + 1; + runIdRef.current = runId; + abortCurrentRun(); + + if (opts?.execute === false || !mountedRef.current) return; + + const lifecycleController = new AbortController(); + const composedAbort = composeAbortSignal(lifecycleController.signal, opts?.signal); + const isCurrentRun = () => ( + mountedRef.current + && runIdRef.current === runId + && abortControllerRef.current === lifecycleController + ); + abortControllerRef.current = lifecycleController; setIsLoading(true); setError(undefined); try { const resolvedUrl = typeof url === 'string' ? url : url.url; - const res = await fetch(resolvedUrl, opts); + const fetchOptions: RequestInit = { + ...(opts || {}), + signal: composedAbort.signal, + }; + const res = await fetch(resolvedUrl, fetchOptions); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); + if (!isCurrentRun()) return; let items: any[]; if (opts?.dataPath) { @@ -59,17 +137,29 @@ export function useStreamJSON( if (!Array.isArray(items)) items = [items]; if (opts?.transform) items = items.map(opts.transform); if (opts?.filter) items = items.filter(opts.filter); + if (!isCurrentRun()) return; setAllItems(items as T[]); - items.forEach((item) => opts?.onData?.(item as T)); + for (const item of items) { + if (!isCurrentRun()) break; + opts?.onData?.(item as T); + } } catch (err) { + if (!isCurrentRun() || lifecycleController.signal.aborted) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - setIsLoading(false); + composedAbort.cleanup(); + const shouldFinishCurrentRun = isCurrentRun(); + if (abortControllerRef.current === lifecycleController) { + abortControllerRef.current = null; + } + if (shouldFinishCurrentRun) { + setIsLoading(false); + } } - }, [url]); + }, [url, abortCurrentRun]); useEffect(() => { fetchAndParse(); diff --git a/src/renderer/src/raycast-api/icon-runtime-assets.tsx b/src/renderer/src/raycast-api/icon-runtime-assets.tsx index 05731627..4bd8a02f 100644 --- a/src/renderer/src/raycast-api/icon-runtime-assets.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-assets.tsx @@ -6,12 +6,37 @@ import React from 'react'; import { getIconRuntimeContext } from './icon-runtime-config'; +const LOCAL_PATH_EXISTS_CACHE_MAX = 4096; +const positiveLocalPathExistsCache = new Set(); +const LOCAL_PATH_MISS_CACHE_MAX = 4096; +const LOCAL_PATH_MISS_CACHE_TTL_MS = 250; +const negativeLocalPathExistsCache = new Map(); + +const CSS_COLOR_CACHE_MAX = 1024; +const normalizedCssColorCache = new Map(); +const validCssColorCache = new Map(); +const parsedCssColorCache = new Map(); +const cssRgbVarCache = new Map(); +const readableTintColorCache = new Map(); +let observedThemeRoot: HTMLElement | null = null; +let themeMutationObserver: MutationObserver | null = null; +let themeCacheVersion = 0; +let lastThemeSignature = ''; + type RgbColor = { r: number; g: number; b: number; }; +function setBoundedCacheValue(cache: Map, key: K, value: V, max = CSS_COLOR_CACHE_MAX): V { + if (!cache.has(key) && cache.size >= max) { + cache.clear(); + } + cache.set(key, value); + return value; +} + export function isEmojiOrSymbol(input: unknown): boolean { const s = typeof input === 'string' ? input.trim() : ''; if (!s) return false; @@ -41,14 +66,47 @@ export function toScAssetUrl(filePath: string): string { function localPathExists(filePath: string): boolean { if (!filePath) return false; + if (positiveLocalPathExistsCache.has(filePath)) return true; + + const nowMs = getLocalPathCacheNowMs(); + const missExpiresAt = negativeLocalPathExistsCache.get(filePath); + if (typeof missExpiresAt === 'number') { + if (missExpiresAt > nowMs) return false; + negativeLocalPathExistsCache.delete(filePath); + } + try { const stat = (window as any).electron?.statSync?.(filePath); - return Boolean(stat?.exists); + const exists = Boolean(stat?.exists); + if (exists) { + if (positiveLocalPathExistsCache.size >= LOCAL_PATH_EXISTS_CACHE_MAX) { + positiveLocalPathExistsCache.clear(); + } + positiveLocalPathExistsCache.add(filePath); + negativeLocalPathExistsCache.delete(filePath); + } else { + cacheLocalPathMiss(filePath, nowMs); + } + return exists; } catch { + cacheLocalPathMiss(filePath, nowMs); return false; } } +function getLocalPathCacheNowMs(): number { + const performanceNow = (globalThis as any).performance?.now; + if (typeof performanceNow === 'function') return performanceNow.call((globalThis as any).performance); + return Date.now(); +} + +function cacheLocalPathMiss(filePath: string, nowMs: number): void { + if (negativeLocalPathExistsCache.size >= LOCAL_PATH_MISS_CACHE_MAX) { + negativeLocalPathExistsCache.clear(); + } + negativeLocalPathExistsCache.set(filePath, nowMs + LOCAL_PATH_MISS_CACHE_TTL_MS); +} + function localPathFromScAssetUrl(src: string): string | null { try { const parsed = new URL(src); @@ -101,69 +159,171 @@ export function resolveIconSrc(src: string, assetsPathOverride?: string): string return raw; } +function getDocumentElement(): HTMLElement | null { + if (typeof document === 'undefined') return null; + return document.documentElement || null; +} + +function readPrefersDark(): boolean { + return Boolean(getDocumentElement()?.classList?.contains('dark')); +} + +function invalidateThemeColorCaches(): void { + themeCacheVersion += 1; + parsedCssColorCache.clear(); + cssRgbVarCache.clear(); + readableTintColorCache.clear(); +} + +function getThemeSignature(root: HTMLElement, prefersDark: boolean): string { + const className = typeof root.className === 'string' + ? root.className + : (root.getAttribute?.('class') || ''); + const styleAttribute = root.getAttribute?.('style') || ''; + return `${prefersDark ? 'dark' : 'light'}|${className}|${styleAttribute}`; +} + +function ensureThemeObserver(root: HTMLElement): void { + if (observedThemeRoot === root) return; + try { + themeMutationObserver?.disconnect(); + } catch { + // Ignore observer cleanup failures. + } + + observedThemeRoot = root; + themeMutationObserver = null; + + if (typeof MutationObserver === 'undefined') return; + + try { + themeMutationObserver = new MutationObserver(() => { + invalidateThemeColorCaches(); + }); + themeMutationObserver.observe(root, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + } catch { + themeMutationObserver = null; + } +} + +function getThemeCacheKey(): string { + const root = getDocumentElement(); + if (!root) return `no-document:${themeCacheVersion}`; + + ensureThemeObserver(root); + const prefersDark = readPrefersDark(); + const signature = getThemeSignature(root, prefersDark); + if (signature !== lastThemeSignature) { + lastThemeSignature = signature; + invalidateThemeColorCaches(); + } + + return `${prefersDark ? 'dark' : 'light'}:${themeCacheVersion}`; +} + +function resolveTintColorString(raw: string): string | undefined { + const normalized = normalizeCssColor(raw); + return isValidCssColor(normalized) ? normalized : undefined; +} + +function canCacheResolvedCssColor(value: string): boolean { + return !/(var\(|env\(|light-dark\(|currentcolor|inherit|initial|revert|unset)/i.test(value); +} + export function resolveTintColor(tintColor: any): string | undefined { if (!tintColor) return undefined; if (typeof tintColor === 'string') { - const normalized = normalizeCssColor(tintColor); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(tintColor); } if (typeof tintColor === 'object') { - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const raw = prefersDark ? (tintColor.dark || tintColor.light) : (tintColor.light || tintColor.dark); if (typeof raw !== 'string') return undefined; - const normalized = normalizeCssColor(raw); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(raw); } return undefined; } function isValidCssColor(value: string): boolean { + if (!value) return false; + const cached = validCssColorCache.get(value); + if (cached !== undefined) return cached; + if (typeof document === 'undefined') return false; + + let isValid = false; try { const el = document.createElement('span'); el.style.color = ''; el.style.color = value; - return Boolean(el.style.color); + isValid = Boolean(el.style.color); } catch { - return false; + isValid = false; } + return setBoundedCacheValue(validCssColorCache, value, isValid); } function normalizeCssColor(value: string): string { + const cached = normalizedCssColorCache.get(value); + if (cached !== undefined) return cached; + const v = value.trim(); - if (/^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v)) return `#${v}`; - return v; + const normalized = /^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v) + ? `#${v}` + : v; + return setBoundedCacheValue(normalizedCssColorCache, value, normalized); } -function parseCssColorToRgb(value: string): RgbColor | null { +function parseCssColorToRgb(value: string, themeKey: string): RgbColor | null { + const canCache = canCacheResolvedCssColor(value); + const cacheKey = `${themeKey}|${value}`; + if (canCache && parsedCssColorCache.has(cacheKey)) return parsedCssColorCache.get(cacheKey) || null; if (typeof document === 'undefined' || !document.body) return null; - const el = document.createElement('span'); - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - el.style.pointerEvents = 'none'; - el.style.color = value; - document.body.appendChild(el); - const computed = window.getComputedStyle(el).color; - el.remove(); - - const match = computed.match(/rgba?\(([^)]+)\)/i); - if (!match) return null; - const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); - if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; - return { - r: Math.max(0, Math.min(255, Math.round(parts[0]))), - g: Math.max(0, Math.min(255, Math.round(parts[1]))), - b: Math.max(0, Math.min(255, Math.round(parts[2]))), - }; + + let parsed: RgbColor | null = null; + try { + const el = document.createElement('span'); + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + el.style.color = value; + document.body.appendChild(el); + const computed = window.getComputedStyle(el).color; + el.remove(); + + const match = computed.match(/rgba?\(([^)]+)\)/i); + if (match) { + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { + parsed = { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; + } + } + } catch { + parsed = null; + } + + return canCache ? setBoundedCacheValue(parsedCssColorCache, cacheKey, parsed) : parsed; } -function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { +function readCssRgbVar(variableName: string, fallback: RgbColor, themeKey: string): RgbColor { + const cacheKey = `${themeKey}|${variableName}|${fallback.r},${fallback.g},${fallback.b}`; + const cached = cssRgbVarCache.get(cacheKey); + if (cached) return cached; + + let resolved = fallback; try { const raw = window.getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); const parts = raw.split(',').map((part) => Number.parseFloat(part.trim())); if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { - return { + resolved = { r: Math.max(0, Math.min(255, Math.round(parts[0]))), g: Math.max(0, Math.min(255, Math.round(parts[1]))), b: Math.max(0, Math.min(255, Math.round(parts[2]))), @@ -172,7 +332,7 @@ function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { } catch { // fall through to fallback } - return fallback; + return setBoundedCacheValue(cssRgbVarCache, cacheKey, resolved); } function mixRgb(base: RgbColor, target: RgbColor, amount: number): RgbColor { @@ -208,16 +368,26 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast const resolved = resolveTintColor(tintColor); if (!resolved) return undefined; - const color = parseCssColorToRgb(resolved); - if (!color) return resolved; + const themeKey = getThemeCacheKey(); + const minContrast = options?.minContrast ?? 4.5; + const canCache = canCacheResolvedCssColor(resolved); + const readableCacheKey = `${themeKey}|${resolved}|${minContrast}`; + const cached = canCache ? readableTintColorCache.get(readableCacheKey) : undefined; + if (cached) return cached; + + const color = parseCssColorToRgb(resolved, themeKey); + if (!color) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const background = readCssRgbVar('--surface-base-rgb', prefersDark ? { r: 30, g: 31, b: 36 } - : { r: 247, g: 248, b: 250 }); - const minContrast = options?.minContrast ?? 4.5; + : { r: 247, g: 248, b: 250 }, themeKey); - if (contrastRatio(color, background) >= minContrast) return resolved; + if (contrastRatio(color, background) >= minContrast) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } const target = prefersDark ? { r: 255, g: 255, b: 255 } @@ -226,11 +396,13 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast for (let step = 1; step <= 12; step += 1) { const adjusted = mixRgb(color, target, step / 12); if (contrastRatio(adjusted, background) >= minContrast) { - return formatRgb(adjusted); + const readable = formatRgb(adjusted); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } } - return formatRgb(mixRgb(color, target, 1)); + const readable = formatRgb(mixRgb(color, target, 1)); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } export function addHexAlpha(color: string, alphaHex: string): string | undefined { diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..8922668b 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import * as Phosphor from '../../../../node_modules/@phosphor-icons/react/dist/index.es.js'; +import * as Phosphor from '@phosphor-icons/react'; import { RAYCAST_ICON_NAMES, RAYCAST_ICON_VALUE_TO_NAME, type RaycastIconName } from './raycast-icon-enum'; type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; @@ -16,9 +16,27 @@ type PhosphorIconComponent = React.ComponentType<{ style?: React.CSSProperties; weight?: PhosphorIconWeight; }>; +type PhosphorIconResolution = { icon: PhosphorIconComponent; weight: PhosphorIconWeight }; type PhosphorExportValue = unknown; +const ICON_RESOLUTION_CACHE_LIMIT = 2048; +const raycastIconNameResolutionCache = new Map(); +const phosphorNameResolutionCache = new Map(); +const phosphorIconResolutionCache = new Map(); + +function setBoundedCacheEntry(cache: Map, key: K, value: V) { + if (!cache.has(key) && cache.size >= ICON_RESOLUTION_CACHE_LIMIT) { + cache.clear(); + } + cache.set(key, value); +} + +function cachePhosphorIconResolution(input: string, result: PhosphorIconResolution | undefined): PhosphorIconResolution | undefined { + setBoundedCacheEntry(phosphorIconResolutionCache, input, result || null); + return result; +} + function normalizeIconName(name: string): string { return String(name || '') .trim() @@ -76,29 +94,61 @@ if (RAYCAST_ICON_VALUE_TO_NAME instanceof Map) { function resolveRaycastIconName(input: string): RaycastIconName | undefined { const rawInput = String(input || '').trim(); + const cached = raycastIconNameResolutionCache.get(rawInput); + if (cached !== undefined || raycastIconNameResolutionCache.has(rawInput)) { + return cached || undefined; + } + const normalized = normalizeIconName(input); - if (!rawInput && !normalized) return undefined; - if (raycastIconNameSet.has(rawInput)) return rawInput as RaycastIconName; - return raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + let resolved: RaycastIconName | undefined; + if (rawInput || normalized) { + resolved = raycastIconNameSet.has(rawInput) + ? rawInput as RaycastIconName + : raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + } + + setBoundedCacheEntry(raycastIconNameResolutionCache, rawInput, resolved || null); + return resolved; } function tryResolvePhosphorByName(name: string): PhosphorIconComponent | undefined { - if (!name) return undefined; + const cacheKey = String(name || ''); + const cached = phosphorNameResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorNameResolutionCache.has(cacheKey)) { + return cached || undefined; + } + + if (!name) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } const direct = (Phosphor as Record)[name]; - if (isRenderablePhosphorComponent(direct)) return direct as PhosphorIconComponent; + if (isRenderablePhosphorComponent(direct)) { + const resolved = direct as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } const normalizedTarget = normalizeIconName(name); - if (!normalizedTarget) return undefined; + if (!normalizedTarget) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } // Some bundling modes can make namespace entries non-enumerable for Object.entries. // getOwnPropertyNames is more robust for resolving the export keys. for (const key of Object.getOwnPropertyNames(Phosphor)) { if (normalizeIconName(key) !== normalizedTarget) continue; const candidate = (Phosphor as Record)[key]; - if (isRenderablePhosphorComponent(candidate)) return candidate as PhosphorIconComponent; + if (isRenderablePhosphorComponent(candidate)) { + const resolved = candidate as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } } + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); return undefined; } @@ -203,7 +253,13 @@ function bestFuzzyPhosphorCandidate(input: string): string | undefined { return bestName || undefined; } -function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComponent; weight: PhosphorIconWeight } | undefined { +function resolvePhosphorIconFromRaycast(input: string): PhosphorIconResolution | undefined { + const cacheKey = String(input || ''); + const cached = phosphorIconResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorIconResolutionCache.has(cacheKey)) { + return cached || undefined; + } + const resolvedRaycastName = resolveRaycastIconName(input); const iconName = (resolvedRaycastName || input || '').replace(/^Icon\./, ''); const normalized = normalizeIconName(iconName); @@ -214,7 +270,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (normalized === 'dot' || normalizedBase === 'dot') { const dotIcon = tryResolvePhosphorByName('Circle'); if (dotIcon) { - return { icon: dotIcon, weight: 'fill' }; + return cachePhosphorIconResolution(cacheKey, { icon: dotIcon, weight: 'fill' }); } } @@ -233,7 +289,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of directCandidates) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -244,7 +300,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of explicitAliases) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -296,7 +352,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of candidates) { const icon = tryResolvePhosphorByName(candidate); if (icon) { - return { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -304,13 +360,13 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (fuzzyCandidate) { const fuzzyResolved = tryResolvePhosphorByName(fuzzyCandidate); if (fuzzyResolved) { - return { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); - if (!fallback) return undefined; - return { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + if (!fallback) return cachePhosphorIconResolution(cacheKey, undefined); + return cachePhosphorIconResolution(cacheKey, { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } export function renderPhosphorIcon(input: string, className: string, tint?: string): React.ReactNode { diff --git a/src/renderer/src/raycast-api/icon-runtime-render.tsx b/src/renderer/src/raycast-api/icon-runtime-render.tsx index 5a4d77ce..88469b02 100644 --- a/src/renderer/src/raycast-api/icon-runtime-render.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-render.tsx @@ -7,30 +7,81 @@ import React, { useEffect, useState } from 'react'; import { isRaycastIconName, renderPhosphorIcon } from './icon-runtime-phosphor'; import { isEmojiOrSymbol, renderTintedAssetIcon, resolveIconSrc, resolveTintColor } from './icon-runtime-assets'; +const FILE_ICON_CACHE_MAX_ENTRIES = 1024; const fileIconCache = new Map(); +const inFlightFileIconRequests = new Map>(); + +function peekCachedFileIcon(filePath: string): string | null | undefined { + if (!fileIconCache.has(filePath)) return undefined; + return fileIconCache.get(filePath) ?? null; +} + +function readCachedFileIcon(filePath: string): string | null | undefined { + const cached = peekCachedFileIcon(filePath); + if (cached === undefined) return undefined; + fileIconCache.delete(filePath); + fileIconCache.set(filePath, cached); + return cached; +} + +function writeCachedFileIcon(filePath: string, src: string | null) { + if (fileIconCache.has(filePath)) fileIconCache.delete(filePath); + fileIconCache.set(filePath, src); + + while (fileIconCache.size > FILE_ICON_CACHE_MAX_ENTRIES) { + const oldestKey = fileIconCache.keys().next().value; + if (oldestKey === undefined) break; + fileIconCache.delete(oldestKey); + } +} + +function loadFileIconDataUrl(filePath: string): Promise { + const cached = readCachedFileIcon(filePath); + if (cached !== undefined) return Promise.resolve(cached); + + const pending = inFlightFileIconRequests.get(filePath); + if (pending) return pending; + + const getFileIconDataUrl = typeof window !== 'undefined' + ? (window as any).electron?.getFileIconDataUrl + : undefined; + if (typeof getFileIconDataUrl !== 'function') return Promise.resolve(null); + + let request: Promise; + try { + request = Promise.resolve(getFileIconDataUrl(filePath, 20)) + .then((iconSrc: string | null | undefined) => { + const normalized = iconSrc || null; + writeCachedFileIcon(filePath, normalized); + return normalized; + }) + .catch(() => null) + .finally(() => { + inFlightFileIconRequests.delete(filePath); + }); + } catch { + return Promise.resolve(null); + } + + inFlightFileIconRequests.set(filePath, request); + return request; +} function FileIcon({ filePath, className }: { filePath: string; className: string }) { - const [src, setSrc] = useState(() => fileIconCache.get(filePath) ?? null); + const [src, setSrc] = useState(() => peekCachedFileIcon(filePath) ?? null); useEffect(() => { let cancelled = false; - const cached = fileIconCache.get(filePath); + const cached = readCachedFileIcon(filePath); if (cached !== undefined) { setSrc(cached); return; } - (window as any).electron?.getFileIconDataUrl?.(filePath, 20) - .then((iconSrc: string | null) => { - if (cancelled) return; - fileIconCache.set(filePath, iconSrc || null); - setSrc(iconSrc || null); - }) - .catch(() => { - if (cancelled) return; - fileIconCache.set(filePath, null); - setSrc(null); - }); + loadFileIconDataUrl(filePath).then((iconSrc) => { + if (cancelled) return; + setSrc(iconSrc); + }); return () => { cancelled = true; diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 95940389..6de16ac6 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -434,8 +434,10 @@ export enum ToastStyle { Failure = 'failure', } +type ToastStyleInput = ToastStyle | 'animated' | 'success' | 'failure'; + export class Toast { - static Style = ToastStyle; + static Style: typeof ToastStyle = ToastStyle; private static _activeToast: Toast | null = null; private _title = ''; @@ -483,7 +485,7 @@ export class Toast { return this._style; } - public set style(value: ToastStyle | Toast.Style | string) { + public set style(value: ToastStyleInput | Toast.Style | string) { this._style = this.normalizeStyle(value); this.refresh(); } @@ -506,7 +508,7 @@ export class Toast { this.refresh(); } - private normalizeStyle(value: ToastStyle | Toast.Style | string | undefined): ToastStyle { + private normalizeStyle(value: ToastStyleInput | Toast.Style | string | undefined): ToastStyle { if (value === ToastStyle.Animated || value === Toast.Style.Animated || value === 'animated') { return ToastStyle.Animated; } @@ -894,16 +896,12 @@ export class Toast { // Toast namespace for types (merged with class) export namespace Toast { - export enum Style { - Animated = 'animated', - Success = 'success', - Failure = 'failure', - } + export type Style = ToastStyle; export interface Options { title: string; message?: string; - style?: ToastStyle | Toast.Style; + style?: ToastStyleInput | Toast.Style; primaryAction?: Alert.ActionOptions; secondaryAction?: Alert.ActionOptions; } @@ -924,9 +922,9 @@ function shouldSuppressBenignGitMissingPathToast(options: Toast.Options): boolea } export async function showToast(options: Toast.Options): Promise; -export async function showToast(style: ToastStyle | Toast.Style, title: string, message?: string): Promise; +export async function showToast(style: ToastStyleInput | Toast.Style, title: string, message?: string): Promise; export async function showToast( - optionsOrStyle: Toast.Options | ToastStyle | Toast.Style, + optionsOrStyle: Toast.Options | ToastStyleInput | Toast.Style, title?: string, message?: string ): Promise { @@ -1387,11 +1385,22 @@ export namespace Cache { export type Subscription = () => void; } +const CACHE_METADATA_VERSION = 2; + +interface CacheStorageMetadata { + version?: number; + lruOrder?: unknown; + sizeByKey?: unknown; + totalSize?: unknown; +} + export class Cache { private storageKey: string; private capacity: number; private subscribers: Set = new Set(); private lruOrder: string[] = []; // Track access order for LRU + private sizeByKey: Map = new Map(); + private totalSize = 0; constructor(options: Cache.Options = {}) { this.capacity = options.capacity ?? 10 * 1024 * 1024; // 10MB default @@ -1403,21 +1412,146 @@ export class Cache { } private loadFromStorage(): void { + let metadata: CacheStorageMetadata | null = null; + let hadStoredMetadata = false; + try { const stored = localStorage.getItem(this.storageKey); + hadStoredMetadata = stored !== null; if (stored) { - const parsed = JSON.parse(stored); - this.lruOrder = parsed.lruOrder || []; + metadata = JSON.parse(stored); } } catch (e) { console.error('Failed to load cache from storage:', e); + this.recoverFromStorage(undefined, true); + return; + } + + if (!metadata || typeof metadata !== 'object') { + this.recoverFromStorage(undefined, hadStoredMetadata); + return; + } + + const lruOrder = this.parseLruOrder(metadata.lruOrder); + if (!lruOrder) { + this.recoverFromStorage(undefined, true); + return; + } + + const storedSizes = this.parseStoredSizes(metadata.sizeByKey); + if (!storedSizes) { + this.recoverFromStorage(lruOrder, true); + return; + } + + let totalSize = 0; + const nextSizes = new Map(); + let hasAllSizes = true; + + for (const key of lruOrder) { + const size = storedSizes.get(key); + if (size === undefined) { + hasAllSizes = false; + break; + } + nextSizes.set(key, size); + totalSize += size; + } + + if (!hasAllSizes) { + this.recoverFromStorage(lruOrder, true); + return; + } + + this.lruOrder = lruOrder; + this.sizeByKey = nextSizes; + this.totalSize = totalSize; + + if ( + metadata.version !== CACHE_METADATA_VERSION || + metadata.totalSize !== totalSize || + storedSizes.size !== lruOrder.length + ) { + this.saveToStorage(); + } + } + + private parseLruOrder(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + + const seen = new Set(); + const order: string[] = []; + for (const key of value) { + if (typeof key !== 'string') return null; + if (seen.has(key)) continue; + seen.add(key); + order.push(key); + } + return order; + } + + private parseStoredSizes(value: unknown): Map | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const sizes = new Map(); + for (const [key, size] of Object.entries(value as Record)) { + if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return null; + sizes.set(key, size); + } + return sizes; + } + + private recoverFromStorage(preferredOrder?: string[], shouldSave = false): void { + this.lruOrder = []; + this.sizeByKey.clear(); + this.totalSize = 0; + + const recovered = new Set(); + const recoverKey = (key: string): void => { + if (recovered.has(key)) return; + recovered.add(key); + + const value = localStorage.getItem(this.getItemKey(key)); + if (value === null) return; + + this.lruOrder.push(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + }; + + if (preferredOrder) { + for (const key of preferredOrder) recoverKey(key); + } else { + const itemPrefix = this.getItemPrefix(); + const storageKeys: string[] = []; + for (let index = 0; index < localStorage.length; index += 1) { + const storageKey = localStorage.key(index); + if (storageKey?.startsWith(itemPrefix)) storageKeys.push(storageKey); + } + + for (const storageKey of storageKeys) { + recoverKey(storageKey.slice(itemPrefix.length)); + } + } + + if (shouldSave || this.lruOrder.length > 0) { + this.saveToStorage(); } } private saveToStorage(): void { try { + const sizeByKey: Record = {}; + for (const key of this.lruOrder) { + const size = this.sizeByKey.get(key); + if (size !== undefined) sizeByKey[key] = size; + } + const data = { + version: CACHE_METADATA_VERSION, lruOrder: this.lruOrder, + sizeByKey, + totalSize: this.totalSize, }; localStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (e) { @@ -1425,19 +1559,16 @@ export class Cache { } } + private getItemPrefix(): string { + return `${this.storageKey}-item-`; + } + private getItemKey(key: string): string { - return `${this.storageKey}-item-${key}`; + return `${this.getItemPrefix()}${key}`; } private getCurrentSize(): number { - let total = 0; - for (const key of this.lruOrder) { - const value = localStorage.getItem(this.getItemKey(key)); - if (value) { - total += value.length; - } - } - return total; + return this.totalSize; } private evictLRU(): void { @@ -1445,6 +1576,9 @@ export class Cache { const oldestKey = this.lruOrder.shift(); if (oldestKey) { localStorage.removeItem(this.getItemKey(oldestKey)); + const size = this.sizeByKey.get(oldestKey) ?? 0; + this.sizeByKey.delete(oldestKey); + this.totalSize = Math.max(0, this.totalSize - size); } } @@ -1458,6 +1592,29 @@ export class Cache { this.lruOrder.push(key); } + private removeTrackedKey(key: string): boolean { + const index = this.lruOrder.indexOf(key); + const trackedSize = this.sizeByKey.get(key); + const existed = index !== -1 || trackedSize !== undefined; + + if (index !== -1) { + this.lruOrder.splice(index, 1); + } + if (trackedSize !== undefined) { + this.sizeByKey.delete(key); + this.totalSize = Math.max(0, this.totalSize - trackedSize); + } + + return existed; + } + + private trackItem(key: string, dataSize: number): void { + this.removeTrackedKey(key); + this.sizeByKey.set(key, dataSize); + this.totalSize += dataSize; + this.updateLRU(key); + } + private notifySubscribers(key: string | undefined, data: string | undefined): void { for (const subscriber of this.subscribers) { try { @@ -1471,10 +1628,18 @@ export class Cache { get(key: string): string | undefined { const value = localStorage.getItem(this.getItemKey(key)); if (value !== null) { + if (this.sizeByKey.get(key) !== value.length) { + this.removeTrackedKey(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + } this.updateLRU(key); this.saveToStorage(); return value; } + if (this.removeTrackedKey(key)) { + this.saveToStorage(); + } return undefined; } @@ -1483,15 +1648,14 @@ export class Cache { const dataSize = data.length; // Check if adding this item would exceed capacity - let currentSize = this.getCurrentSize(); - while (currentSize + dataSize > this.capacity && this.lruOrder.length > 0) { + this.removeTrackedKey(key); + while (this.getCurrentSize() + dataSize > this.capacity && this.lruOrder.length > 0) { this.evictLRU(); - currentSize = this.getCurrentSize(); } // Store the item localStorage.setItem(itemKey, data); - this.updateLRU(key); + this.trackItem(key, dataSize); this.saveToStorage(); // Notify subscribers @@ -1500,14 +1664,11 @@ export class Cache { remove(key: string): boolean { const itemKey = this.getItemKey(key); - const existed = localStorage.getItem(itemKey) !== null; + const existedInStorage = localStorage.getItem(itemKey) !== null; + const existed = this.removeTrackedKey(key) || existedInStorage; if (existed) { localStorage.removeItem(itemKey); - const index = this.lruOrder.indexOf(key); - if (index !== -1) { - this.lruOrder.splice(index, 1); - } this.saveToStorage(); this.notifySubscribers(key, undefined); } @@ -1516,7 +1677,22 @@ export class Cache { } has(key: string): boolean { - return localStorage.getItem(this.getItemKey(key)) !== null; + const value = localStorage.getItem(this.getItemKey(key)); + if (value !== null) { + if (this.sizeByKey.get(key) !== value.length) { + this.removeTrackedKey(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + this.updateLRU(key); + this.saveToStorage(); + } + return true; + } + + if (this.removeTrackedKey(key)) { + this.saveToStorage(); + } + return false; } get isEmpty(): boolean { @@ -1531,6 +1707,8 @@ export class Cache { localStorage.removeItem(this.getItemKey(key)); } this.lruOrder = []; + this.sizeByKey.clear(); + this.totalSize = 0; this.saveToStorage(); // Notify subscribers @@ -1646,9 +1824,30 @@ class StreamingPromise implements PromiseLike { } // Global IPC listener registry — routes chunks to the right StreamingPromise -const _activeStreams = new Map(); +type ActiveAIStream = { + sp: StreamingPromise; + fullText: string; + abortSignal?: AbortSignal; + abortHandler?: () => void; +}; + +const _activeStreams = new Map(); let _aiListenersRegistered = false; +function finalizeAIStream(requestId: string): ActiveAIStream | undefined { + const entry = _activeStreams.get(requestId); + if (!entry) return undefined; + + if (entry.abortSignal && entry.abortHandler) { + entry.abortSignal.removeEventListener('abort', entry.abortHandler); + entry.abortSignal = undefined; + entry.abortHandler = undefined; + } + + _activeStreams.delete(requestId); + return entry; +} + function ensureAIListeners(): void { if (_aiListenersRegistered) return; _aiListenersRegistered = true; @@ -1665,18 +1864,16 @@ function ensureAIListeners(): void { }); electron.onAIStreamDone?.((data: { requestId: string }) => { - const entry = _activeStreams.get(data.requestId); + const entry = finalizeAIStream(data.requestId); if (entry) { entry.sp._complete(entry.fullText); - _activeStreams.delete(data.requestId); } }); electron.onAIStreamError?.((data: { requestId: string; error: string }) => { - const entry = _activeStreams.get(data.requestId); + const entry = finalizeAIStream(data.requestId); if (entry) { entry.sp._error(new Error(data.error)); - _activeStreams.delete(data.requestId); } }); } @@ -1729,10 +1926,9 @@ export const AI = { model: options?.model, creativity, }).catch((err: any) => { - const entry = _activeStreams.get(requestId); + const entry = finalizeAIStream(requestId); if (entry) { entry.sp._error(err); - _activeStreams.delete(requestId); } }); @@ -1740,17 +1936,22 @@ export const AI = { if (options?.signal) { if (options.signal.aborted) { electron.aiCancel?.(requestId); - setTimeout(() => sp._error(new Error('Request aborted')), 0); - _activeStreams.delete(requestId); + const entry = finalizeAIStream(requestId); + setTimeout(() => entry?.sp._error(new Error('Request aborted')), 0); } else { - options.signal.addEventListener('abort', () => { + const abortHandler = () => { electron.aiCancel?.(requestId); - const entry = _activeStreams.get(requestId); + const entry = finalizeAIStream(requestId); if (entry) { entry.sp._error(new Error('Request aborted')); - _activeStreams.delete(requestId); } - }, { once: true }); + }; + const entry = _activeStreams.get(requestId); + if (entry) { + entry.abortSignal = options.signal; + entry.abortHandler = abortHandler; + options.signal.addEventListener('abort', abortHandler, { once: true }); + } } } diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..768f373b 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -4,14 +4,43 @@ * Extracted list registry/grouping helpers to keep List container module small. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; +export const LIST_ROW_HEIGHT = 36; +export const LIST_HEADER_HEIGHT = 24; +export const LIST_OVERSCAN = 8; +export const EMOJI_GRID_COLUMNS = 8; +export const EMOJI_GRID_CELL_HEIGHT = 96; +export const EMOJI_GRID_ROW_GAP = 8; +export const EMOJI_GRID_ROW_HEIGHT = EMOJI_GRID_CELL_HEIGHT + EMOJI_GRID_ROW_GAP; +export const EMOJI_GRID_HEADER_HEIGHT = 28; + +export type ListItemGroup = { + title?: string; + items: { item: ItemRegistration; globalIdx: number }[]; +}; + +export type ListVirtualRow = + | { type: 'header'; title: string; key: string; height: number } + | { type: 'item'; item: ItemRegistration; globalIdx: number; key: string; height: number }; + +export type EmojiGridVirtualRow = + | { type: 'header'; title: string; count: number; key: string; height: number } + | { type: 'emoji-row'; items: ListItemGroup['items']; key: string; height: number }; + +export type VirtualRow = ListVirtualRow | EmojiGridVirtualRow; + +export type VirtualRowMetrics = { + offsets: number[]; + totalHeight: number; +}; + function getReactTypeName(type: any): string { return String(type?.displayName || type?.name || type || ''); } -function buildSnapshotSignature(value: unknown, seen = new WeakSet()): string { +function buildValueSignature(value: unknown, seen = new WeakSet()): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (typeof value === 'string') return JSON.stringify(value); @@ -20,21 +49,22 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s if (typeof value === 'symbol') return value.toString(); if (Array.isArray(value)) { - return `[${value.map((item) => buildSnapshotSignature(item, seen)).join(',')}]`; + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; } if (React.isValidElement(value)) { - return `element:${getReactTypeName(value.type)}:${buildSnapshotSignature((value as any).props, seen)}`; + return `element:${getReactTypeName(value.type)}:${buildValueSignature((value as any).props, seen)}`; } if (typeof value === 'object') { + if (value instanceof Date) return `date:${value.getTime()}`; if (seen.has(value as object)) return '[circular]'; seen.add(value as object); const entries = Object.entries(value as Record) .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => `${key}:${buildSnapshotSignature(entryValue, seen)}`); + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); return `{${entries.join(',')}}`; } @@ -42,64 +72,103 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s return String(value); } +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function buildListAccessorySignature(accessory: NonNullable[number]): string { + return [ + buildValueSignature(accessory?.text), + buildValueSignature(accessory?.icon), + buildValueSignature(accessory?.tag), + buildValueSignature(accessory?.date), + buildValueSignature(accessory?.tooltip), + ].join('\u001e'); +} + +export function buildListItemVisibleSignature(item: ItemRegistration): string { + const props = item.props; + return [ + item.id, + String(item.order), + item.sectionTitle || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildValueSignature(props.icon), + props.accessories?.map(buildListAccessorySignature).join('\u001d') || '', + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildValueSignature(props.detail), + buildValueSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + export function useListRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); + + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + }; + }, []); const scheduleRegistryUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { - pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()).map((item) => { - const actionType = item.props.actions?.type as any; - const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return buildSnapshotSignature({ - actionName, - accessories: item.props.accessories, - detail: item.props.detail, - icon: item.props.icon, - id: item.id, - keywords: item.props.keywords, - order: item.order, - sectionTitle: item.sectionTitle || '', - subtitle: item.props.subtitle, - title: item.props.title, - }); - }).join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); + if (!mountedRef.current) { + pendingRef.current = false; + return; } + pendingRef.current = false; + setRegistryVersion((value) => value + 1); }); }, []); const registryAPI = useMemo(() => ({ set(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { // Hot path: an unrelated re-render (e.g. hover changing selection) // re-runs every render even though no content actually // changed. The renderOrder counter shifts uniformly so relative // order is preserved; just write it through and skip the costly - // snapshot recompute. Only schedule an update when props identity - // or section actually changed. + // visible-signature work. Only publish when the id's visible + // signature, section, or order actually changed. const propsChanged = existing.props !== data.props; const sectionChanged = existing.sectionTitle !== data.sectionTitle; + const orderChanged = existing.order !== data.order; existing.props = data.props; existing.sectionTitle = data.sectionTitle; existing.order = data.order; - if (!propsChanged && !sectionChanged) return; + if (!propsChanged && !sectionChanged && !orderChanged) return; + + const nextSignature = buildListItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const item = { id, ...data }; + registryRef.current.set(id, item); + visibleSignatureRef.current.set(id, buildListItemVisibleSignature(item)); } scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), [scheduleRegistryUpdate]); @@ -155,8 +224,8 @@ export function shouldUseEmojiGrid(filteredItems: ItemRegistration[], isShowingD return emojiIcons / Math.max(1, iconsWithValue) >= 0.95; } -export function groupListItems(filteredItems: ItemRegistration[]) { - const groups: { title?: string; items: { item: ItemRegistration; globalIdx: number }[] }[] = []; +export function groupListItems(filteredItems: ItemRegistration[]): ListItemGroup[] { + const groups: ListItemGroup[] = []; let currentSection: string | undefined | null = null; let globalIndex = 0; @@ -170,3 +239,105 @@ export function groupListItems(filteredItems: ItemRegistration[]) { return groups; } + +export function buildListVirtualRows(groupedItems: ListItemGroup[]): ListVirtualRow[] { + const rows: ListVirtualRow[] = []; + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}`, height: LIST_HEADER_HEIGHT }); + } + for (const entry of group.items) { + rows.push({ + type: 'item', + item: entry.item, + globalIdx: entry.globalIdx, + key: entry.item.id, + height: LIST_ROW_HEIGHT, + }); + } + } + return rows; +} + +export function buildEmojiGridVirtualRows(groupedItems: ListItemGroup[], columns = EMOJI_GRID_COLUMNS): EmojiGridVirtualRow[] { + const rows: EmojiGridVirtualRow[] = []; + const safeColumns = Math.max(1, Math.floor(columns)); + + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ + type: 'header', + title: group.title, + count: group.items.length, + key: `__eg_h_${groupIndex}`, + height: EMOJI_GRID_HEADER_HEIGHT, + }); + } + + for (let start = 0; start < group.items.length; start += safeColumns) { + rows.push({ + type: 'emoji-row', + items: group.items.slice(start, start + safeColumns), + key: `__eg_r_${groupIndex}_${start}`, + height: EMOJI_GRID_ROW_HEIGHT, + }); + } + } + + return rows; +} + +export function measureVirtualRows(rows: Array<{ height: number }>): VirtualRowMetrics { + const offsets: number[] = new Array(rows.length); + let totalHeight = 0; + for (let index = 0; index < rows.length; index += 1) { + offsets[index] = totalHeight; + totalHeight += rows[index].height; + } + return { offsets, totalHeight }; +} + +export function getVisibleVirtualRange( + rows: Array<{ height: number }>, + rowMetrics: VirtualRowMetrics, + scrollTop: number, + containerHeight: number, + overscan = LIST_OVERSCAN, +) { + if (rows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; + + const top = scrollTop; + const bottom = scrollTop + (containerHeight || 600); + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (rowMetrics.offsets[mid] + rows[mid].height <= top) lo = mid + 1; + else hi = mid; + } + + const visibleStart = Math.max(0, lo - overscan); + let visibleEnd = lo; + while (visibleEnd < rows.length && rowMetrics.offsets[visibleEnd] < bottom) visibleEnd += 1; + visibleEnd = Math.min(rows.length, visibleEnd + overscan); + return { visibleStart, visibleEnd }; +} + +export function buildItemToVirtualRowMap(rows: VirtualRow[], itemCount: number): number[] { + const map: number[] = new Array(itemCount); + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (row.type === 'item') { + map[row.globalIdx] = rowIndex; + } else if (row.type === 'emoji-row') { + for (const entry of row.items) { + map[entry.globalIdx] = rowIndex; + } + } + } + + return map; +} diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..59df5e0e 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -19,7 +19,7 @@ interface ListRendererDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (tintColor?: string) => string | undefined; resolveReadableTintColor: (tintColor?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; } export function createListRenderers(deps: ListRendererDeps) { @@ -53,13 +53,14 @@ export function createListRenderers(deps: ListRendererDeps) { const registry = useContext(ListRegistryContext); const sectionTitle = useContext(ListSectionTitleContext); const stableId = useRef(props.id || `__li_${++itemOrderCounter}`).current; - const renderOrder = ++itemOrderCounter; + const orderRef = useRef(null); + if (orderRef.current === null) orderRef.current = ++itemOrderCounter; const selCtx = useContext(SelectedItemActionsContext); useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: renderOrder }); + registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, renderOrder, sectionTitle, stableId]); + }, [props, registry, sectionTitle, stableId]); // When this item is selected, render its actions within the extension's // context tree so that per-item React contexts (e.g. VaultItemContext) @@ -102,7 +103,7 @@ export function createListRenderers(deps: ListRendererDeps) { {icon &&
{renderIcon(icon, iconClassName, assetsPath)}
}
{primaryText}
{secondaryText && {secondaryText}} - {accessories?.map((accessory, index) => { + {accessories?.map((accessory: NonNullable[number], index: number) => { const accessoryText = typeof accessory?.text === 'string' ? accessory.text : typeof accessory?.text === 'object' ? accessory.text?.value || '' : ''; const accessoryTextColorRaw = typeof accessory?.text === 'object' ? accessory.text?.color : undefined; const tagText = typeof accessory?.tag === 'string' ? accessory.tag : typeof accessory?.tag === 'object' ? accessory.tag?.value || '' : ''; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..5ccc45d3 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -10,7 +10,19 @@ import type { ExtractedAction } from './action-runtime'; import { useI18n } from '../i18n'; import { transliterateForSearch } from '../utils/transliterate'; import { createListDetailRuntime } from './list-runtime-detail'; -import { groupListItems, shouldUseEmojiGrid, useListRegistry } from './list-runtime-hooks'; +import { + buildEmojiGridVirtualRows, + buildItemToVirtualRowMap, + buildListVirtualRows, + EMOJI_GRID_CELL_HEIGHT, + EMOJI_GRID_COLUMNS, + EMOJI_GRID_ROW_GAP, + getVisibleVirtualRange, + groupListItems, + measureVirtualRows, + shouldUseEmojiGrid, + useListRegistry, +} from './list-runtime-hooks'; import { createListRenderers } from './list-runtime-renderers'; import { EmptyViewRegistryContext, @@ -34,7 +46,7 @@ interface ListRuntimeDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (value?: string) => string | undefined; resolveReadableTintColor: (value?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; getExtensionContext: () => { assetsPath: string; extensionDisplayName?: string; @@ -188,8 +200,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { if (event.key === 'ArrowRight' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? 8 : 1), filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? 8 : 1), 0)); + else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), filteredItems.length - 1)); + else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), 0)); else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; @@ -255,39 +267,19 @@ export function createListRuntime(deps: ListRuntimeDeps) { const groupedItems = useMemo(() => groupListItems(filteredItems), [filteredItems]); - // ─── Viewport virtualization for the linear (non-grid) list ───── - // Brew-style extensions ship ~5k items; rendering all of them as DOM - // nodes is the bottleneck. We render only the slice in view (plus a - // small buffer) and pad the scroll container with spacer divs so the - // scrollbar still represents the full list. - const ROW_HEIGHT = 36; - const HEADER_HEIGHT = 24; - const OVERSCAN = 8; - - const flatRows = useMemo(() => { - const rows: Array< - | { type: 'header'; title: string; key: string } - | { type: 'item'; item: typeof filteredItems[number]; globalIdx: number; key: string } - > = []; - for (let g = 0; g < groupedItems.length; g += 1) { - const group = groupedItems[g]; - if (group.title) rows.push({ type: 'header', title: group.title, key: `__h_${g}` }); - for (const entry of group.items) { - rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); - } - } - return rows; - }, [groupedItems]); - - const rowMetrics = useMemo(() => { - const offsets: number[] = new Array(flatRows.length); - let cum = 0; - for (let i = 0; i < flatRows.length; i += 1) { - offsets[i] = cum; - cum += flatRows[i].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - } - return { offsets, totalHeight: cum }; - }, [flatRows]); + // ─── Viewport virtualization for list rows and emoji grid rows ───── + // Emoji-heavy extensions can ship thousands of cells. Both layouts render + // only the rows in view plus a buffer and use spacers to preserve scroll. + const listRows = useMemo(() => { + if (shouldUseEmojiGridValue) return []; + return buildListVirtualRows(groupedItems); + }, [groupedItems, shouldUseEmojiGridValue]); + const emojiGridRows = useMemo( + () => shouldUseEmojiGridValue ? buildEmojiGridVirtualRows(groupedItems) : [], + [groupedItems, shouldUseEmojiGridValue] + ); + const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows; + const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]); const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); @@ -320,45 +312,23 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; }, []); - const { visibleStart, visibleEnd } = useMemo(() => { - if (flatRows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; - const top = scrollTop; - const bottom = scrollTop + (containerHeight || 600); - let lo = 0; - let hi = flatRows.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const rowH = flatRows[mid].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - if (rowMetrics.offsets[mid] + rowH <= top) lo = mid + 1; - else hi = mid; - } - const start = Math.max(0, lo - OVERSCAN); - let end = lo; - while (end < flatRows.length && rowMetrics.offsets[end] < bottom) end += 1; - end = Math.min(flatRows.length, end + OVERSCAN); - return { visibleStart: start, visibleEnd: end }; - }, [flatRows, rowMetrics, scrollTop, containerHeight]); - - // Map from filteredItems index → flat row index for scroll-into-view. + const { visibleStart, visibleEnd } = useMemo( + () => getVisibleVirtualRange(virtualRows, rowMetrics, scrollTop, containerHeight), + [containerHeight, rowMetrics, scrollTop, virtualRows], + ); + + // Map from filteredItems index → active virtual row index for scroll-into-view. const itemIdxToRowIdx = useMemo(() => { - const map: number[] = new Array(filteredItems.length); - let itemSeen = -1; - for (let i = 0; i < flatRows.length; i += 1) { - if (flatRows[i].type === 'item') { - itemSeen += 1; - map[itemSeen] = i; - } - } - return map; - }, [flatRows, filteredItems.length]); + return buildItemToVirtualRowMap(virtualRows, filteredItems.length); + }, [filteredItems.length, virtualRows]); // Stable refs so the scroll-into-view effect only fires when the user - // moves selection — not when upstream re-renders give flatRows/rowMetrics/ - // itemIdxToRowIdx fresh identities. Without this, scrolling the wheel + // moves selection — not when upstream re-renders give virtualRows/ + // rowMetrics/itemIdxToRowIdx fresh identities. Without this, scrolling the wheel // triggers any unrelated re-render → effect re-runs → snaps back to // selectedIdx. - const flatRowsRef = useRef(flatRows); - flatRowsRef.current = flatRows; + const virtualRowsRef = useRef(virtualRows); + virtualRowsRef.current = virtualRows; const rowMetricsRef = useRef(rowMetrics); rowMetricsRef.current = rowMetrics; const itemIdxToRowIdxRef = useRef(itemIdxToRowIdx); @@ -367,11 +337,15 @@ export function createListRuntime(deps: ListRuntimeDeps) { useEffect(() => { const el = listRef.current; if (!el) return; + if (shouldUseEmojiGridValue) { + el.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'auto' }); + return; + } const rowIdx = itemIdxToRowIdxRef.current[selectedIdx]; if (rowIdx == null) return; const top = rowMetricsRef.current.offsets[rowIdx]; if (top == null) return; - const rowH = flatRowsRef.current[rowIdx]?.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; + const rowH = virtualRowsRef.current[rowIdx]?.height || 0; const visTop = el.scrollTop; const visBottom = visTop + el.clientHeight; // 'auto' (instant) — smooth scrolling queues animations that interrupt @@ -381,7 +355,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { } else if (top + rowH > visBottom) { el.scrollTo({ top: top + rowH - el.clientHeight, behavior: 'auto' }); } - }, [selectedIdx]); + }, [selectedIdx, shouldUseEmojiGridValue]); const extensionContext = getExtensionContext(); const footerTitle = navigationTitle || extInfo.extensionDisplayName || extensionContext.extensionDisplayName || extensionContext.extensionName || 'Extension'; @@ -390,16 +364,22 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const rawDetailElement = rawDetail as React.ReactElement<{ children?: React.ReactNode }>; + const children = React.Children.toArray(rawDetailElement.props.children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; for (const child of children) { if (!React.isValidElement(child)) continue; if ((child.type as any) !== ListItemDetail) continue; - if (child.props.markdown !== undefined) mergedMarkdown = child.props.markdown; - if (child.props.metadata !== undefined) mergedMetadata = child.props.metadata; - if (child.props.isLoading !== undefined) mergedIsLoading = child.props.isLoading; + const detailProps = child.props as { + markdown?: string; + metadata?: React.ReactElement; + isLoading?: boolean; + }; + if (detailProps.markdown !== undefined) mergedMarkdown = detailProps.markdown; + if (detailProps.metadata !== undefined) mergedMetadata = detailProps.metadata; + if (detailProps.isLoading !== undefined) mergedIsLoading = detailProps.isLoading; } if (mergedMarkdown === undefined && mergedMetadata === undefined) return rawDetail; return React.createElement(ListItemDetail, { @@ -416,40 +396,72 @@ export function createListRuntime(deps: ListRuntimeDeps) { ) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : shouldUseEmojiGridValue ? ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}{group.items.length}
} -
- {group.items.map(({ item, globalIdx }) => { - const title = typeof item.props.title === 'string' ? item.props.title : (item.props.title as any)?.value || ''; + (() => { + const startOffset = rowMetrics.offsets[visibleStart] || 0; + const endOffset = visibleEnd < rowMetrics.offsets.length + ? rowMetrics.offsets[visibleEnd] + : rowMetrics.totalHeight; + const bottomSpacer = Math.max(0, rowMetrics.totalHeight - endOffset); + return ( + <> + {startOffset > 0 && -
- )) + {bottomSpacer > 0 &&