diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..93b44df5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,8 @@ on: jobs: claude-review: + # Fork PRs cannot receive the OIDC token required by this action. + 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 +43,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..9f5536c5 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -2,15 +2,44 @@ // 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. +// By default this stays intentionally lightweight for dependency-free helpers. +// Tests that need production modules with a few heavy dependencies can pass +// stubs keyed by import specifier. import { transform } from 'esbuild'; import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; -export async function importTs(absPath) { +function toDataUrl(code) { + return 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +} + +async function transformTs(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }); + return code; +} + +function rewriteImportSpecifiers(code, absPath, options) { + const stubs = options.stubs || {}; + const stubUrls = new Map(Object.keys(stubs).map((specifier) => [specifier, toDataUrl(stubs[specifier])])); + + const rewriteSpecifier = (specifier) => { + if (stubUrls.has(specifier)) return stubUrls.get(specifier); + if ((specifier.startsWith('./') || specifier.startsWith('../')) && options.root) { + const resolved = path.resolve(path.dirname(absPath), specifier); + return pathToFileURL(resolved).href; + } + return specifier; + }; + + return code + .replace(/(\bfrom\s*["'])([^"']+)(["'])/g, (_match, prefix, specifier, suffix) => `${prefix}${rewriteSpecifier(specifier)}${suffix}`) + .replace(/(\bimport\s*["'])([^"']+)(["'])/g, (_match, prefix, specifier, suffix) => `${prefix}${rewriteSpecifier(specifier)}${suffix}`); +} + +export async function importTs(absPath, options = {}) { 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'); - return import(dataUrl); + const code = rewriteImportSpecifiers(await transformTs(src), absPath, options); + return import(toDataUrl(code)); } diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs index 695347ae..e30db23c 100644 --- a/scripts/measure-extension-bundle-cache.mjs +++ b/scripts/measure-extension-bundle-cache.mjs @@ -94,6 +94,7 @@ export async function bundleExtensionRunner(userDataDir) { entryPoints: [path.join(root, 'src/main/extension-runner.ts')], outfile: outFile, bundle: true, + external: ['esbuild'], platform: 'node', format: 'cjs', target: 'node20', diff --git a/scripts/test-ai-provider-stream-parser-bounds.mjs b/scripts/test-ai-provider-stream-parser-bounds.mjs new file mode 100644 index 00000000..ebcfecca --- /dev/null +++ b/scripts/test-ai-provider-stream-parser-bounds.mjs @@ -0,0 +1,213 @@ +#!/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 REQUEST_STUB = ` +import { EventEmitter } from 'node:events'; +export function request(options, callback) { + const harness = globalThis.__aiProviderStreamParserHarness; + if (!harness) throw new Error('AI provider stream parser harness is not installed'); + return harness.request(options, callback); +} +export default { request }; +`; + +const { + AI_STREAM_PARSER_MAX_BUFFER_CHARS, + streamAI, +} = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { + http: REQUEST_STUB, + https: REQUEST_STUB, + }, +}); + +const OPENAI_CONFIG = { + enabled: true, + provider: 'openai', + openaiApiKey: 'test-openai-key', +}; + +const OLLAMA_CONFIG = { + enabled: true, + provider: 'ollama', + ollamaBaseUrl: 'http://127.0.0.1:11434', +}; + +function createRequestHarness() { + const requests = []; + + return { + requests, + request(options, callback) { + const record = { + options, + body: '', + destroyed: false, + response: null, + }; + + 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 installHarness() { + const harness = createRequestHarness(); + globalThis.__aiProviderStreamParserHarness = 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 AI provider request'); +} + +function openAISseFrame(text) { + return `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`; +} + +function ollamaNdjsonLine(text) { + return `${JSON.stringify({ response: text })}\n`; +} + +function splitInsideUtf8Sequence(text, sequence) { + const buffer = Buffer.from(text); + const needle = Buffer.from(sequence); + const index = buffer.indexOf(needle); + assert.notEqual(index, -1, `expected payload to contain ${sequence}`); + return [ + buffer.subarray(0, index + 1), + buffer.subarray(index + 1), + ]; +} + +async function startStream(config, options = {}) { + const harness = installHarness(); + const chunks = []; + const collecting = (async () => { + for await (const chunk of streamAI(config, { + prompt: 'stream parser harness prompt', + creativity: 0.2, + ...options, + })) { + chunks.push(chunk); + } + return chunks; + })(); + + const request = await waitForRequestResponse(harness); + return { collecting, chunks, request }; +} + +test('SSE parser preserves UTF-8 characters split across provider chunks', async (t) => { + const expected = 'Hello 🙂 world'; + const frame = openAISseFrame(expected); + const parts = splitInsideUtf8Sequence(frame, '🙂'); + const { collecting, request } = await startStream(OPENAI_CONFIG); + + request.response.write(parts[0]); + request.response.write(parts[1]); + request.response.end('data: [DONE]\n\n'); + + assert.deepEqual(await collecting, [expected]); + + const legacyDecoded = parts.map((part) => part.toString()).join(''); + assert.notEqual(legacyDecoded, frame); + t.diagnostic(`legacy split-SSE decode matched source: ${legacyDecoded === frame}`); + t.diagnostic('TextDecoder streaming split-SSE decode matched source: true'); +}); + +test('NDJSON parser preserves UTF-8 characters split across provider chunks', async (t) => { + const expected = 'Ollama says 🙂'; + const line = ollamaNdjsonLine(expected); + const parts = splitInsideUtf8Sequence(line, '🙂'); + const { collecting, request } = await startStream(OLLAMA_CONFIG); + + request.response.write(parts[0]); + request.response.write(parts[1]); + request.response.end(); + + assert.deepEqual(await collecting, [expected]); + + const legacyDecoded = parts.map((part) => part.toString()).join(''); + assert.notEqual(legacyDecoded, line); + t.diagnostic(`legacy split-NDJSON decode matched source: ${legacyDecoded === line}`); + t.diagnostic('TextDecoder streaming split-NDJSON decode matched source: true'); +}); + +test('stream parsers keep valid long provider payloads below the buffer limit working', async () => { + const longText = 'provider-payload-'.repeat(16 * 1024); + const sse = await startStream(OPENAI_CONFIG); + sse.request.response.end(openAISseFrame(longText) + 'data: [DONE]\n\n'); + assert.deepEqual(await sse.collecting, [longText]); + + const ndjson = await startStream(OLLAMA_CONFIG); + ndjson.request.response.end(ollamaNdjsonLine(longText)); + assert.deepEqual(await ndjson.collecting, [longText]); +}); + +test('SSE parser allows an incomplete line at the configured buffer limit', async (t) => { + const { collecting, request } = await startStream(OPENAI_CONFIG); + + request.response.write('x'.repeat(AI_STREAM_PARSER_MAX_BUFFER_CHARS)); + request.response.end('\n'); + + assert.deepEqual(await collecting, []); + t.diagnostic(`bounded SSE retained-buffer limit accepted: ${AI_STREAM_PARSER_MAX_BUFFER_CHARS} characters`); +}); + +test('SSE parser rejects an unterminated line above the configured buffer limit', async (t) => { + const { collecting, request } = await startStream(OPENAI_CONFIG); + + request.response.write('x'.repeat(AI_STREAM_PARSER_MAX_BUFFER_CHARS + 1)); + + await assert.rejects( + collecting, + /AI stream parser exceeded \d+ buffered characters without a line break/, + ); + request.response.destroy(); + t.diagnostic(`legacy retained incomplete SSE characters: ${AI_STREAM_PARSER_MAX_BUFFER_CHARS + 1}`); + t.diagnostic(`bounded retained incomplete SSE characters: ${AI_STREAM_PARSER_MAX_BUFFER_CHARS}`); +}); + +test('NDJSON parser rejects an unterminated line above the configured buffer limit', async (t) => { + const { collecting, request } = await startStream(OLLAMA_CONFIG); + + request.response.write('x'.repeat(AI_STREAM_PARSER_MAX_BUFFER_CHARS + 1)); + + await assert.rejects( + collecting, + /AI stream parser exceeded \d+ buffered characters without a line break/, + ); + request.response.destroy(); + t.diagnostic(`legacy retained incomplete NDJSON characters: ${AI_STREAM_PARSER_MAX_BUFFER_CHARS + 1}`); + t.diagnostic(`bounded retained incomplete NDJSON characters: ${AI_STREAM_PARSER_MAX_BUFFER_CHARS}`); +}); diff --git a/scripts/test-background-refresh-inflight.mjs b/scripts/test-background-refresh-inflight.mjs new file mode 100644 index 00000000..d74fbae4 --- /dev/null +++ b/scripts/test-background-refresh-inflight.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); + +const { createInFlightBackgroundRefreshTick } = await importTs( + path.join(rootDir, 'src/renderer/src/hooks/backgroundRefreshTimers.ts') +); + +function deferred() { + let resolve; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +test('extension background refresh skips overlapping ticks while runExtension is in flight', async () => { + let starts = 0; + let active = 0; + let maxConcurrent = 0; + let dispatches = 0; + const firstRun = deferred(); + const secondRun = deferred(); + const runs = [firstRun, secondRun]; + + const tick = createInFlightBackgroundRefreshTick(async () => { + const run = runs[starts]; + starts += 1; + active += 1; + maxConcurrent = Math.max(maxConcurrent, active); + await run.promise; + active -= 1; + dispatches += 1; + }); + + tick(); + await flushMicrotasks(); + tick(); + tick(); + await flushMicrotasks(); + + assert.equal(starts, 1); + assert.equal(maxConcurrent, 1); + assert.equal(dispatches, 0); + + firstRun.resolve(); + await flushMicrotasks(); + + tick(); + await flushMicrotasks(); + assert.equal(starts, 2); + assert.equal(maxConcurrent, 1); + + secondRun.resolve(); + await flushMicrotasks(); + assert.equal(dispatches, 2); +}); + +test('inline script background refresh skips ticks until run and fetchCommands finish', async () => { + let scriptStarts = 0; + let fetchStarts = 0; + let active = 0; + let maxConcurrent = 0; + const scriptRun = deferred(); + const fetchRun = deferred(); + + const tick = createInFlightBackgroundRefreshTick(async () => { + scriptStarts += 1; + active += 1; + maxConcurrent = Math.max(maxConcurrent, active); + await scriptRun.promise; + fetchStarts += 1; + await fetchRun.promise; + active -= 1; + }); + + tick(); + await flushMicrotasks(); + tick(); + await flushMicrotasks(); + + assert.equal(scriptStarts, 1); + assert.equal(fetchStarts, 0); + assert.equal(maxConcurrent, 1); + + scriptRun.resolve(); + await flushMicrotasks(); + tick(); + await flushMicrotasks(); + + assert.equal(scriptStarts, 1); + assert.equal(fetchStarts, 1); + + fetchRun.resolve(); + await flushMicrotasks(); + tick(); + await flushMicrotasks(); + + assert.equal(scriptStarts, 2); + assert.equal(maxConcurrent, 1); +}); + +test('independent background refresh timers can run at the same time', async () => { + let extensionStarts = 0; + let scriptStarts = 0; + let active = 0; + let maxConcurrent = 0; + const extensionRun = deferred(); + const scriptRun = deferred(); + + const extensionTick = createInFlightBackgroundRefreshTick(async () => { + extensionStarts += 1; + active += 1; + maxConcurrent = Math.max(maxConcurrent, active); + await extensionRun.promise; + active -= 1; + }); + const scriptTick = createInFlightBackgroundRefreshTick(async () => { + scriptStarts += 1; + active += 1; + maxConcurrent = Math.max(maxConcurrent, active); + await scriptRun.promise; + active -= 1; + }); + + extensionTick(); + scriptTick(); + await flushMicrotasks(); + + assert.equal(extensionStarts, 1); + assert.equal(scriptStarts, 1); + assert.equal(maxConcurrent, 2); + + extensionTick(); + scriptTick(); + await flushMicrotasks(); + assert.equal(extensionStarts, 1); + assert.equal(scriptStarts, 1); + + extensionRun.resolve(); + scriptRun.resolve(); + await flushMicrotasks(); +}); 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-extension-builtin-require-priority.mjs b/scripts/test-extension-builtin-require-priority.mjs new file mode 100644 index 00000000..2c052097 --- /dev/null +++ b/scripts/test-extension-builtin-require-priority.mjs @@ -0,0 +1,82 @@ +#!/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 extensionViewPath = path.resolve('src/renderer/src/ExtensionView.tsx'); +const source = fs.readFileSync(extensionViewPath, 'utf8'); + +function extractBetween(startNeedle, endNeedle) { + const start = source.indexOf(startNeedle); + assert.notEqual(start, -1, `Could not locate ${startNeedle}`); + const end = source.indexOf(endNeedle, start); + assert.notEqual(end, -1, `Could not locate ${endNeedle}`); + return source.slice(start, end); +} + +function loadBuiltinFacadeHarness() { + const helperSource = extractBetween( + 'const SUPERCMD_BUILTIN_FACADE_MODULES', + 'const superCmdBuiltinFacadeCache' + ); + const transpiled = ts.transpileModule( + `${helperSource} +globalThis.__builtinFacadeHarness = { + shouldUseSuperCmdBuiltinFacade, + facadeModules: Array.from(SUPERCMD_BUILTIN_FACADE_MODULES), +};`, + { + compilerOptions: { + module: ts.ModuleKind.None, + target: ts.ScriptTarget.ES2022, + }, + fileName: extensionViewPath, + } + ); + const sandbox = {}; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: extensionViewPath }); + return sandbox.__builtinFacadeHarness; +} + +test('extension require keeps compatibility facades ahead of real Node builtins', () => { + const { shouldUseSuperCmdBuiltinFacade, facadeModules } = loadBuiltinFacadeHarness(); + + assert.equal(JSON.stringify(facadeModules), JSON.stringify(['fs', 'fs/promises', 'child_process'])); + assert.equal(shouldUseSuperCmdBuiltinFacade('fs'), true); + assert.equal(shouldUseSuperCmdBuiltinFacade('node:fs'), true); + assert.equal(shouldUseSuperCmdBuiltinFacade('fs/promises'), true); + assert.equal(shouldUseSuperCmdBuiltinFacade('child_process'), true); + assert.equal(shouldUseSuperCmdBuiltinFacade('node:child_process'), true); + + for (const builtin of ['crypto', 'node:crypto', 'zlib', 'node:zlib']) { + assert.equal( + shouldUseSuperCmdBuiltinFacade(builtin), + false, + `${builtin} should fall through to real Node before the compatibility stub` + ); + } +}); + +test('fakeRequire falls back to stubs only after real builtins fail', () => { + const builtinRequireBlock = extractBetween( + 'Node.js built-in modules', + 'Swift native bridges' + ); + + const timerFacadeIndex = builtinRequireBlock.indexOf('isExtensionTimerBuiltinRequest(name)'); + const superCmdFacadeIndex = builtinRequireBlock.indexOf('shouldUseSuperCmdBuiltinFacade(name)'); + const realRequireIndex = builtinRequireBlock.indexOf('tryRealNodeRequire(name)'); + const stubFallbackIndex = builtinRequireBlock.indexOf('if (name in nodeBuiltinStubs)'); + + assert.ok(timerFacadeIndex >= 0, 'timer builtins should keep their scoped facade path'); + assert.ok(superCmdFacadeIndex > timerFacadeIndex, 'SuperCmd-only facades should follow timer facades'); + assert.ok(realRequireIndex > superCmdFacadeIndex, 'real Node require should run after SuperCmd-only facades'); + assert.ok(stubFallbackIndex > realRequireIndex, 'compatibility stubs should be the final builtin fallback'); +}); 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..5a93eac6 --- /dev/null +++ b/scripts/test-extension-fetch-proxy-abort.mjs @@ -0,0 +1,197 @@ +#!/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 binaryPending = []; + 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, requestId) => { + binaryRequests.push({ url, requestId }); + const run = deferred(); + binaryPending.push(run); + return run.promise; + }, + }; + installExtensionFetchBridge(g, () => electron); + return { binaryPending, binaryRequests, canceled, g, pending, 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 follow-up and ignores stale bytes', async () => { + const { binaryPending, binaryRequests, canceled, g, pending, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://cdn.test/logo.png', { signal: abort.signal }); + await flushAsync(); + + assert.equal(requests.length, 1); + assert.equal(abort.listenerCount(), 1); + + pending[0].resolve({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'image/png' }, + bodyText: '', + url: 'https://cdn.test/logo.png', + }); + await flushAsync(); + + assert.equal(binaryRequests.length, 1); + assert.equal(binaryRequests[0].url, 'https://cdn.test/logo.png'); + assert.equal(binaryRequests[0].requestId, requests[0].requestId); + assert.equal(abort.listenerCount(), 1); + + abort.abort(); + abort.abort(); + assert.deepEqual(canceled, [requests[0].requestId]); + + binaryPending[0].resolve(new Uint8Array([1, 2, 3])); + + 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..70f93a3c --- /dev/null +++ b/scripts/test-extension-lifecycle-sandbox.mjs @@ -0,0 +1,797 @@ +#!/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, + }; + Object.defineProperty(hostWindow, 'nonConfigurableProbe', { + value: 'descriptor-probe', + configurable: false, + enumerable: true, + }); + + 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, + createExtensionTimerBuiltinFacade, + createExtensionLifecycleScope, + createTimerRegistry, + isExtensionTimerBuiltinRequest, + }; + `; + 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, + createExtensionTimerBuiltinFacade, + createExtensionLifecycleScope, + createTimerRegistry, + isExtensionTimerBuiltinRequest, +} = 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 createLegacyImportedTimerFacade(name) { + const normalized = name.startsWith('node:') ? name.slice(5) : name; + if (normalized === 'timers/promises') { + const wait = (ms, value) => new Promise((resolve) => { + host.hostWindow.setTimeout(() => resolve(value), ms); + }); + return { + setTimeout: wait, + setInterval: async function* (ms, value) { + while (true) { + yield await wait(ms, value); + } + }, + setImmediate: (value) => Promise.resolve(value), + scheduler: { wait }, + }; + } + + return { + setImmediate: (callback, ...args) => host.hostWindow.setTimeout(() => callback(...args), 0), + clearImmediate: (id) => host.hostWindow.clearTimeout(id), + setInterval: (...args) => host.hostWindow.setInterval(...args), + clearInterval: (id) => host.hostWindow.clearInterval(id), + setTimeout: (...args) => host.hostWindow.setTimeout(...args), + clearTimeout: (id) => host.hostWindow.clearTimeout(id), + }; +} + +function createScopedImportedTimerRequire(lifecycleScope) { + const facades = new Map(); + return (name) => { + assert.equal(isExtensionTimerBuiltinRequest(name), true, `expected ${name} to be a timer builtin`); + const normalized = name.startsWith('node:') ? name.slice(5) : name; + if (!facades.has(normalized)) { + facades.set(normalized, createExtensionTimerBuiltinFacade(normalized, lifecycleScope)); + } + return facades.get(normalized); + }; +} + +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(); +} + +const importedTimerCode = ` +const timers = require("timers"); +const nodeTimers = require("node:timers"); +const timersPromises = require("timers/promises"); +const nodeTimersPromises = require("node:timers/promises"); + +exports.default = function ImportedTimerProbe() { + const intervalId = timers.setInterval(() => {}, 60000); + const timeoutId = nodeTimers.setTimeout(() => {}, 60000); + const waitPromise = timersPromises.setTimeout(60000, "waited"); + const intervalIterator = nodeTimersPromises.setInterval(60000, "tick"); + const intervalNextPromise = intervalIterator.next(); + + return { + intervalId, + timeoutId, + waitPromise, + intervalIterator, + intervalNextPromise + }; +}; +`; + +function runImportedTimerWrapper({ lifecycleScope, scoped }) { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const fakeRequire = scoped + ? createScopedImportedTimerRequire(lifecycleScope) + : (name) => createLegacyImportedTimerFacade(name); + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, importedTimerCode); + + wrapper( + moduleExports, + fakeRequire, + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + host.hostWindow, + host.hostWindow, + host.hostWindow.setTimeout, + host.hostWindow.clearTimeout, + host.hostWindow.setInterval, + host.hostWindow.clearInterval, + host.hostWindow.setTimeout, + host.hostWindow.clearTimeout, + host.hostWindow.requestAnimationFrame, + host.hostWindow.cancelAnimationFrame, + host.hostWindow, + host.hostDocument, + 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.notEqual(lifecycleScope.scopedWindow, host.hostWindow); + assert.notEqual(lifecycleScope.scopedDocument, host.hostDocument); + assert.equal(lifecycleScope.scopedWindow.window, lifecycleScope.scopedWindow); + assert.equal(lifecycleScope.scopedWindow.self, lifecycleScope.scopedWindow); + assert.equal(lifecycleScope.scopedWindow.globalThis, lifecycleScope.scopedWindow); + assert.equal(lifecycleScope.scopedWindow.document, lifecycleScope.scopedDocument); + assert.equal(lifecycleScope.scopedDocument.defaultView, lifecycleScope.scopedWindow); + assert.equal(lifecycleScope.scopedWindow.nonConfigurableProbe, 'descriptor-probe'); + assert.equal(Object.keys(lifecycleScope.scopedWindow).includes('nonConfigurableProbe'), true); + assert.equal( + Object.getOwnPropertyDescriptor(lifecycleScope.scopedWindow, 'nonConfigurableProbe')?.configurable, + true, + 'scoped proxies expose host descriptors as configurable to satisfy proxy invariants' + ); + lifecycleScope.scopedWindow.proxyWriteProbe = 'written-through-proxy'; + assert.equal(host.hostWindow.proxyWriteProbe, 'written-through-proxy'); + let methodThis = null; + host.hostWindow.methodProbe = function () { + methodThis = this; + return this.navigator.userAgent; + }; + assert.equal(lifecycleScope.scopedWindow.methodProbe(), 'SuperCmd lifecycle harness'); + assert.equal(methodThis, host.hostWindow); + 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('documents the pre-fix imported timer module leak', () => { + host.reset(); + const registry = createTimerRegistry(); + runImportedTimerWrapper({ scoped: false }); + const beforeClear = host.snapshot(); + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.timeouts, 3); + assert.equal(registry.intervals.size, 0, 'legacy imported timers bypass the lifecycle registry'); + assert.equal(registry.timeouts.size, 0, 'legacy imported timer promises bypass the lifecycle registry'); + assert.equal(afterClear.intervals, beforeClear.intervals); + assert.equal(afterClear.timeouts, beforeClear.timeouts); + + console.log('imported timer module leak before scoped facade:', { beforeClear, afterClear }); + host.reset(); + }); + + await t.test('cleans imported timer module handles on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + runImportedTimerWrapper({ lifecycleScope, scoped: true }); + const beforeClear = host.snapshot(); + const registryBeforeClear = { + intervals: registry.intervals.size, + timeouts: registry.timeouts.size, + }; + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.timeouts, 3); + assert.deepEqual(registryBeforeClear, { intervals: 1, timeouts: 3 }); + assert.equal(afterClear.total, 0); + + console.log('imported timer module cleanup after scoped facade:', { + beforeClear, + registryBeforeClear, + afterClear, + }); + }); + + await t.test('prunes fired imported one-shots from timer builtins', async () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const timers = createExtensionTimerBuiltinFacade('timers', lifecycleScope); + const timersPromises = createExtensionTimerBuiltinFacade('timers/promises', lifecycleScope); + let callbackCalls = 0; + + timers.setTimeout(() => { + callbackCalls += 1; + }, 0); + const waitPromise = timersPromises.setTimeout(0, 'timeout-value'); + const schedulerPromise = timersPromises.scheduler.wait(0); + const immediatePromise = timersPromises.setImmediate('immediate-value'); + + assert.equal(registry.timeouts.size, 4); + assert.equal(host.snapshot().timeouts, 4); + + host.flushTimeouts(); + + assert.equal(callbackCalls, 1); + assert.equal(await waitPromise, 'timeout-value'); + assert.equal(await schedulerPromise, undefined); + assert.equal(await immediatePromise, 'immediate-value'); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(host.snapshot().timeouts, 0); + + console.log('imported timer one-shot pruning after scoped facade:', { + firedOneShots: 4, + registryTimeoutsAfterFlush: registry.timeouts.size, + hostTimeoutsAfterFlush: host.snapshot().timeouts, + }); + }); + + 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..a0f0728a --- /dev/null +++ b/scripts/test-extension-runner-multi-command-build.mjs @@ -0,0 +1,350 @@ +#!/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 extName = `multi-build-fixture-${commandCount}`; + const extDir = path.join(userDataDir, 'extensions', extName); + 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: extName, + title: `Multi Build Fixture ${commandCount}`, + description: 'Fixture for batched extension builds', + owner: 'codex', + commands, + }, + null, + 2 + ) + ); + + return { tmpDir, userDataDir, extDir, extName }; +} + +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; + const entryNames = Array.isArray(options.entryPoints) + ? options.entryPoints.map((entryPoint) => path.basename(entryPoint, path.extname(entryPoint))) + : Object.keys(options.entryPoints || {}); + const started = performance.now(); + const result = await originalBuild.call(this, options); + buildCalls.push({ + entryCount, + entryNames, + outdir: options.outdir, + outfile: options.outfile, + durationMs: Number((performance.now() - started).toFixed(2)), + }); + return result; + } + 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('buildSingleCommand stamp lets buildAllCommands reuse on-demand bundle', async () => { + const fixture = createBuildFixture(3); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + const singleStarted = performance.now(); + assert.equal(await runner.buildSingleCommand(fixture.extName, 'command-0'), true); + const singleElapsedMs = Number((performance.now() - singleStarted).toFixed(2)); + + const fullStarted = performance.now(); + assert.equal(await runner.buildAllCommands(fixture.extName, fixture.extDir), 3); + const fullElapsedMs = Number((performance.now() - fullStarted).toFixed(2)); + + assert.equal(buildCalls.length, 2, 'expected one on-demand build and one partial full build'); + assert.equal(buildCalls[0].entryCount, 1); + assert.deepEqual(buildCalls[0].entryNames, ['command-0']); + assert.equal(buildCalls[0].outfile, path.join(fixture.extDir, '.sc-build', 'command-0.js')); + assert.equal(buildCalls[1].entryCount, 2, 'expected full build to skip on-demand-stamped command'); + assert.deepEqual(buildCalls[1].entryNames.sort(), ['command-1', 'command-2']); + assert.equal(buildCalls[1].outdir, path.join(fixture.extDir, '.sc-build')); + + const stamp = JSON.parse( + fs.readFileSync(path.join(fixture.extDir, '.sc-build', '.sc-build-stamp.json'), 'utf-8') + ); + assert.deepEqual( + stamp.commands.map((command) => command.name).sort(), + ['command-0', 'command-1', 'command-2'] + ); + + console.log(JSON.stringify({ + scenario: 'on-demand-single-then-full-build', + elapsedMs: { + single: singleElapsedMs, + full: fullElapsedMs, + }, + esbuildCalls: buildCalls.map((call) => ({ + entryNames: call.entryNames, + durationMs: call.durationMs, + })), + })); + }); + } 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-http-download-binary-cancel.mjs b/scripts/test-http-download-binary-cancel.mjs new file mode 100644 index 00000000..bb53d3e8 --- /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-native-helper-process-lifecycle.mjs b/scripts/test-native-helper-process-lifecycle.mjs new file mode 100644 index 00000000..4a5e98a7 --- /dev/null +++ b/scripts/test-native-helper-process-lifecycle.mjs @@ -0,0 +1,478 @@ +#!/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 nativeHelperLifecyclePath = path.resolve('src/main/native-helper-lifecycle.ts'); +const nativeHelperLifecycleSource = fs.readFileSync(nativeHelperLifecyclePath, 'utf8'); +const NATIVE_HELPER_LINE_BUFFER_MAX_CHARS = 256 * 1024; + +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; +} + +function appendBoundedLineBufferForTest(buffer, chunk, maxChars = NATIVE_HELPER_LINE_BUFFER_MAX_CHARS) { + const combined = buffer + chunk.toString(); + const rawLines = combined.split('\n'); + let nextBuffer = rawLines.pop() ?? ''; + let truncated = false; + const lines = []; + + for (const line of rawLines) { + lines.push(line); + } + + if (nextBuffer.length > maxChars) { + nextBuffer = nextBuffer.slice(-maxChars); + truncated = true; + } + + return { buffer: nextBuffer, lines, truncated }; +} + +function appendBoundedTextBufferForTest(buffer, chunk, maxChars = NATIVE_HELPER_LINE_BUFFER_MAX_CHARS) { + const combined = buffer + chunk.toString(); + if (combined.length <= maxChars) { + return { buffer: combined, truncated: false }; + } + return { buffer: combined.slice(-maxChars), truncated: true }; +} + +function createReadinessWaitForTest(options) { + let settled = false; + let timeout = null; + let resolvePromise = null; + let rejectPromise = null; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + resolvePromise = null; + rejectPromise = null; + }; + + const settle = (error) => { + if (settled) return; + settled = true; + const resolve = resolvePromise; + const reject = rejectPromise; + cleanup(); + if (error) { + reject?.(error); + } else { + resolve?.(); + } + }; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + timeout = setTimeout(() => { + if (!options.isActive()) { + settle(new Error(options.supersededMessage)); + return; + } + settle(new Error(options.timeoutMessage)); + options.kill(); + }, options.timeoutMs); + }); + + return { + promise, + markReady: () => { + if (!options.isActive()) { + settle(new Error(options.supersededMessage)); + return; + } + if (options.isKilled()) { + settle(new Error(options.diedMessage)); + return; + } + settle(); + }, + reject: (error) => { + settle(error); + }, + }; +} + +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, + `kill: () => ${helper.kill}(child)`, + `${helper.label} startup timeout should only kill the child it started` + ); + expectContains( + ensureSource, + `isActive: () => ${helper.processVar} === child`, + `${helper.label} startup wait should detect a superseded child by identity` + ); + expectContains( + ensureSource, + 'appendNativeHelperLineBuffer', + `${helper.label} stdout parser should cap partial native-helper lines` + ); + assert.equal( + countOccurrences(ensureSource, 'setInterval('), + 0, + `${helper.label} startup readiness should not poll` + ); + } +}); + +test('native helper lifecycle utility source keeps readiness event-driven and buffers bounded', () => { + expectContains( + nativeHelperLifecycleSource, + 'export const NATIVE_HELPER_LINE_BUFFER_MAX_CHARS = 256 * 1024;', + 'native helper partial-line buffer cap should be explicit' + ); + assert.equal( + countOccurrences(nativeHelperLifecycleSource, 'setInterval('), + 0, + 'native helper readiness utility should not poll' + ); + assert.equal( + countOccurrences(nativeHelperLifecycleSource, 'setTimeout('), + 1, + 'native helper readiness utility should keep one startup timeout' + ); + expectContains( + nativeHelperLifecycleSource, + 'nextBuffer = nextBuffer.slice(-maxChars);', + 'native helper buffer should keep a bounded suffix for malformed partial lines' + ); + expectContains( + nativeHelperLifecycleSource, + 'export function appendNativeHelperTextBuffer', + 'native helper stderr/plain text buffers should have a shared cap' + ); +}); + +test('native helper readiness wait uses one timeout and no polling interval', async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const originalSetInterval = globalThis.setInterval; + const originalClearInterval = globalThis.clearInterval; + + let timeoutCount = 0; + let intervalCount = 0; + const timers = new Map(); + let nextTimerId = 1; + + globalThis.setTimeout = (callback, delay) => { + const id = nextTimerId++; + timeoutCount += 1; + timers.set(id, { callback, delay, cleared: false }); + return id; + }; + globalThis.clearTimeout = (id) => { + const timer = timers.get(id); + if (timer) timer.cleared = true; + }; + globalThis.setInterval = () => { + intervalCount += 1; + throw new Error('readiness wait must not allocate an interval'); + }; + globalThis.clearInterval = () => {}; + + try { + let active = true; + let killed = false; + const wait = createReadinessWaitForTest({ + timeoutMs: 120_000, + timeoutMessage: 'timeout', + supersededMessage: 'superseded', + diedMessage: 'died', + isActive: () => active, + isKilled: () => killed, + kill: () => { + killed = true; + active = false; + }, + }); + + assert.equal(timeoutCount, 1, 'event-driven readiness should keep only the startup timeout'); + assert.equal(intervalCount, 0, 'event-driven readiness should not allocate polling intervals'); + + wait.markReady(); + await wait.promise; + assert.ok([...timers.values()].every((timer) => timer.cleared), 'ready event should clear the startup timeout'); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + globalThis.setInterval = originalSetInterval; + globalThis.clearInterval = originalClearInterval; + } +}); + +test('native helper line buffers cap partial output while preserving complete lines', () => { + const oversizedCompleteLine = `{"text":"${'z'.repeat(NATIVE_HELPER_LINE_BUFFER_MAX_CHARS + 128)}"}`; + const complete = appendBoundedLineBufferForTest('', `${oversizedCompleteLine}\n`); + + assert.equal(complete.truncated, false); + assert.deepEqual(complete.lines, [oversizedCompleteLine]); + assert.equal(complete.buffer, ''); + + const oversizedPartial = 'x'.repeat(NATIVE_HELPER_LINE_BUFFER_MAX_CHARS + 128); + const first = appendBoundedLineBufferForTest('', oversizedPartial); + + assert.equal(first.truncated, true); + assert.equal(first.lines.length, 0); + assert.equal(first.buffer.length, NATIVE_HELPER_LINE_BUFFER_MAX_CHARS); + + const second = appendBoundedLineBufferForTest(first.buffer, '\n{"ready":true}\n{"text":"hel'); + assert.equal(second.truncated, false, 'complete newline-terminated lines should be emitted'); + assert.equal(second.lines[0].length, NATIVE_HELPER_LINE_BUFFER_MAX_CHARS); + assert.equal(second.lines[1], '{"ready":true}'); + assert.equal(second.buffer, '{"text":"hel'); + + const third = appendBoundedLineBufferForTest(second.buffer, 'lo"}\n'); + assert.equal(third.truncated, false); + assert.deepEqual(third.lines, ['{"text":"hello"}']); + assert.equal(third.buffer, ''); +}); + +test('native helper text buffers cap stderr and plain output suffixes', () => { + const oversized = `prefix-${'y'.repeat(NATIVE_HELPER_LINE_BUFFER_MAX_CHARS)}-tail`; + const result = appendBoundedTextBufferForTest('', oversized); + + assert.equal(result.truncated, true); + assert.equal(result.buffer.length, NATIVE_HELPER_LINE_BUFFER_MAX_CHARS); + assert.ok(result.buffer.endsWith('-tail')); +}); + +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`); + } +}); + +test('guarded lifecycle rejects and clears the active pending request on kill', () => { + for (const helper of helpers) { + const harness = createLifecycleHarness({ guarded: true, label: helper.label }); + const child = harness.spawn('active'); + harness.setPending(); + + harness.kill(child); + + assert.equal(harness.activeProcess, null, `${helper.label} active process should clear on kill`); + assert.equal(harness.pendingRequest, null, `${helper.label} pending request should clear on kill`); + assert.deepEqual(harness.rejections, [`${helper.label} killed`]); + } +}); 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/src/main/ai-provider.ts b/src/main/ai-provider.ts index 65942908..16995445 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -6,8 +6,11 @@ import * as https from 'https'; import * as http from 'http'; +import { TextDecoder } from 'util'; import type { AISettings } from './settings-store'; +export const AI_STREAM_PARSER_MAX_BUFFER_CHARS = 4 * 1024 * 1024; + export interface AIRequestOptions { prompt: string; model?: string; @@ -687,6 +690,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 +727,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); @@ -740,13 +772,15 @@ async function* parseSSE( extractChunk: (data: string) => string | null ): AsyncGenerator { let buffer = ''; + const decoder = new TextDecoder('utf-8'); - for await (const rawChunk of response) { - buffer += rawChunk.toString(); - - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // keep incomplete line + const assertBufferWithinLimit = () => { + if (buffer.length > AI_STREAM_PARSER_MAX_BUFFER_CHARS) { + throw new Error(`AI stream parser exceeded ${AI_STREAM_PARSER_MAX_BUFFER_CHARS} buffered characters without a line break`); + } + }; + function* processCompleteLines(lines: string[]): Generator { for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data: ')) continue; @@ -756,6 +790,27 @@ async function* parseSSE( } } + for await (const rawChunk of response) { + buffer += typeof rawChunk === 'string' + ? rawChunk + : decoder.decode(rawChunk as Uint8Array, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // keep incomplete line + assertBufferWithinLimit(); + + yield* processCompleteLines(lines); + } + + const remainingDecoded = decoder.decode(); + if (remainingDecoded) { + buffer += remainingDecoded; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + assertBufferWithinLimit(); + yield* processCompleteLines(lines); + } + // Process remaining buffer if (buffer.trim()) { const trimmed = buffer.trim(); @@ -772,13 +827,15 @@ async function* parseNDJSON( extractChunk: (obj: any) => string | null ): AsyncGenerator { let buffer = ''; + const decoder = new TextDecoder('utf-8'); - for await (const rawChunk of response) { - buffer += rawChunk.toString(); - - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + const assertBufferWithinLimit = () => { + if (buffer.length > AI_STREAM_PARSER_MAX_BUFFER_CHARS) { + throw new Error(`AI stream parser exceeded ${AI_STREAM_PARSER_MAX_BUFFER_CHARS} buffered characters without a line break`); + } + }; + function* processCompleteLines(lines: string[]): Generator { for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -792,6 +849,27 @@ async function* parseNDJSON( } } + for await (const rawChunk of response) { + buffer += typeof rawChunk === 'string' + ? rawChunk + : decoder.decode(rawChunk as Uint8Array, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + assertBufferWithinLimit(); + + yield* processCompleteLines(lines); + } + + const remainingDecoded = decoder.decode(); + if (remainingDecoded) { + buffer += remainingDecoded; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + assertBufferWithinLimit(); + yield* processCompleteLines(lines); + } + if (buffer.trim()) { try { const obj = JSON.parse(buffer.trim()); @@ -857,6 +935,23 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { const body = Buffer.concat(parts); 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', @@ -873,26 +968,29 @@ 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); diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..03350a32 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,9 @@ 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; +let extensionCommandInfoRunnerForTesting: (() => CommandInfo[]) | null = null; // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -178,6 +181,43 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + extensionCommandInfoRunnerForTesting = null; + commandsDiskCachePath = null; +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options?: { cacheTimestamp?: number } +): void { + cachedCommands = commands; + staleCommandsFallback = commands; + cacheTimestamp = options?.cacheTimestamp ?? Date.now(); +} + +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 +697,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 +1402,107 @@ 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 || {}; + for (const cmd of commands) { + const metadata = commandMetadata[cmd.id] || (cmd.path ? commandMetadata[cmd.path] : undefined); + if (!(cmd.category === 'script' && cmd.mode !== 'inline') && metadata?.subtitle !== undefined) { + const subtitle = String(metadata.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 {} +} + +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 +2079,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 +2128,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 { @@ -2044,6 +2164,34 @@ export async function refreshCommandsNow(): Promise { 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) { diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 2a275382..1ddf240f 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'; @@ -119,6 +120,42 @@ interface FsPathSignature { mtimeMs: number; } +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[]; +} + +interface ExtensionBuildInput { + requiresNodeModules: boolean; + tsconfigRaw: string; + buildContext: Omit; +} + +const extensionBuildStampVersion = 1; +const extensionBuildStampFile = '.sc-build-stamp.json'; + interface InstalledExtensionSourceSignature { extName: string; extPath: string; @@ -202,6 +239,62 @@ function getPathSignature(filePath: string): FsPathSignature { } } +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 samePathSignature(a: FsPathSignature, b: FsPathSignature): boolean { return ( a.exists === b.exists && @@ -226,6 +319,25 @@ function sameRootSnapshot( 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 getInstalledExtensionSourceSignature(source: InstalledExtensionSource): InstalledExtensionSourceSignature { return { extName: source.extName, @@ -660,6 +772,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 @@ -833,6 +982,218 @@ 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 getManifestExternal(pkg: any): string[] { + return Array.isArray(pkg?.external) + ? pkg.external.filter((v: any) => typeof v === 'string' && v.trim().length > 0) + : []; +} + +function createExtensionBuildInput( + extName: string, + extPath: string, + pkg: any +): ExtensionBuildInput { + const manifestExternal = getManifestExternal(pkg); + const tsconfigRaw = getEsbuildTsconfigRaw(extPath); + return { + requiresNodeModules: extensionRequiresNodeModules(pkg), + tsconfigRaw, + buildContext: createExtensionBuildContext(extName, extPath, pkg, manifestExternal, tsconfigRaw), + }; +} + +function createCommonExtensionBuildOptions( + extPath: string, + extNodeModules: string, + buildInput: ExtensionBuildInput +): Record { + return { + absWorkingDir: extPath, + bundle: true, + format: 'cjs', + platform: 'node', + plugins: [createNativeSchemeExternalPlugin()], + external: buildInput.buildContext.external, + nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], + target: 'es2020', + jsx: 'automatic', + jsxImportSource: 'react', + tsconfigRaw: buildInput.tsconfigRaw, + define: { + 'process.env.NODE_ENV': '"production"', + 'global': 'globalThis', + }, + logLevel: 'warning', + metafile: true, + }; +} + +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)), + }; +} + +function writeMergedCommandBuildStamp( + buildDir: string, + buildContext: Omit, + commandStamp: ExtensionCommandBuildStamp +): void { + const previousStamp = readExtensionBuildStamp(buildDir); + const nextCommandStamps = new Map(); + + if (sameExtensionBuildContext(previousStamp, buildContext)) { + for (const existingStamp of previousStamp.commands) { + nextCommandStamps.set(existingStamp.name, existingStamp); + } + } + + nextCommandStamps.set(commandStamp.name, commandStamp); + writeExtensionBuildStamp(buildDir, { + ...buildContext, + commands: [...nextCommandStamps.values()].sort((a, b) => a.name.localeCompare(b.name)), + }); +} + /** * Build ALL commands for an installed extension using esbuild. * Called at install time so the extension is ready to run instantly. @@ -858,8 +1219,7 @@ export async function buildAllCommands(extName: string, extPathOverride?: string invalidateExtensionStaticCacheForPath(extPath); let commands: any[]; - let requiresNodeModules = false; - let manifestExternal: string[] = []; + let buildInput: ExtensionBuildInput; try { const pkg = readExtensionManifest(extPath); if (!pkg) return 0; @@ -868,130 +1228,158 @@ export async function buildAllCommands(extName: string, extPathOverride?: string return 0; } commands = pkg.commands || []; - requiresNodeModules = extensionRequiresNodeModules(pkg); - manifestExternal = Array.isArray(pkg.external) - ? pkg.external.filter((v: any) => typeof v === 'string' && v.trim().length > 0) - : []; + buildInput = createExtensionBuildInput(extName, extPath, pkg); } catch { return 0; } 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 buildContext = buildInput.buildContext; + 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 (buildInput.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 = createCommonExtensionBuildOptions(extPath, extNodeModules, buildInput); + + 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}`); @@ -1155,8 +1543,7 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom invalidateExtensionStaticCacheForPath(extPath); let cmd: any; - let requiresNodeModules = false; - let manifestExternal: string[] = []; + let buildInput: ExtensionBuildInput; try { const pkg = readExtensionManifest(extPath); if (!pkg) return false; @@ -1166,10 +1553,7 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom } const commands = pkg.commands || []; cmd = commands.find((c: any) => c.name === cmdName); - requiresNodeModules = extensionRequiresNodeModules(pkg); - manifestExternal = Array.isArray(pkg.external) - ? pkg.external.filter((v: any) => typeof v === 'string' && v.trim().length > 0) - : []; + buildInput = createExtensionBuildInput(extName, extPath, pkg); } catch (e: any) { console.error(`buildSingleCommand: failed to parse package.json for ${extName}:`, e?.message); return false; @@ -1197,7 +1581,7 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom const extNodeModules = path.join(extPath, 'node_modules'); // If node_modules is missing, install dependencies first - if (requiresNodeModules && !fs.existsSync(extNodeModules)) { + if (buildInput.requiresNodeModules && !fs.existsSync(extNodeModules)) { console.log(` node_modules missing for ${extName}, installing dependencies…`); try { const { installExtensionDeps } = require('./extension-registry'); @@ -1212,51 +1596,25 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom try { const esbuild = requireEsbuild(); console.log(` On-demand building ${extName}/${cmdName}…`); - await runEsbuildBuild( + const result = await runEsbuildBuild( esbuild, { - entryPoints: [entryFile], - absWorkingDir: extPath, - bundle: true, - format: 'cjs', - platform: 'node', + ...createCommonExtensionBuildOptions(extPath, extNodeModules, buildInput), outfile: outFile, - plugins: [ - { - name: 'native-scheme-external', - setup(build: any) { - build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ - path: args.path, - external: true, - })); - }, - }, - ], - external: [ - '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, - ], - nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], - target: 'es2020', - jsx: 'automatic', - jsxImportSource: 'react', - tsconfigRaw: getEsbuildTsconfigRaw(extPath), - define: { - 'process.env.NODE_ENV': '"production"', - 'global': 'globalThis', - }, - logLevel: 'warning', + entryPoints: [entryFile], }, extPath, `${extName}/${cmdName}` ); - return fs.existsSync(outFile); + const built = fs.existsSync(outFile); + if (built) { + writeMergedCommandBuildStamp( + buildDir, + buildInput.buildContext, + createCommandBuildStamp(extPath, cmd, entryFile, outFile, result?.metafile) + ); + } + return built; } catch (e: any) { console.error(` On-demand esbuild failed for ${extName}/${cmdName}:`, e); lastBuildError.set(`${extName}/${cmdName}`, e?.message || String(e)); @@ -1312,9 +1670,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; @@ -1330,7 +1688,7 @@ async function runEsbuildBuild( ); throw error; } - await esbuild.build(options); + return await esbuild.build(options); } } 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/main.ts b/src/main/main.ts index 94985e40..c9ddfd71 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; -import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; +import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow, refreshCommandsForExtensionChange } from './commands'; import { loadSettings, saveSettings, @@ -68,6 +68,13 @@ import { mergeAiChatSnapshot, upsertAiChatConversation, } from './ai-chat-store'; +import { decodeHttpResponseBodyBuffer } from './http-response-decode'; +import { + appendNativeHelperLineBuffer, + appendNativeHelperTextBuffer, + createNativeHelperReadinessWait, + type NativeHelperReadinessWait, +} from './native-helper-lifecycle'; import { getExtensionPreferences, getExtensionPreferencesSnapshot, @@ -264,18 +271,30 @@ let parakeetServerStarting: Promise | null = null; let parakeetServerBuffer = ''; type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let parakeetPendingRequest: PendingParakeetRequest | null = null; +let parakeetServerReadyWait: NativeHelperReadinessWait | null = null; + +const nativeHelperLineBufferTruncationWarnings = new Set(); + +function warnNativeHelperLineBufferTruncated(helperName: string): void { + if (nativeHelperLineBufferTruncationWarnings.has(helperName)) return; + nativeHelperLineBufferTruncationWarnings.add(helperName); + console.warn(`[${helperName}] Native helper output buffer exceeded limit; old data was discarded`); +} -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 = ''; + parakeetServerReadyWait?.reject(new Error('Parakeet server killed')); + parakeetServerReadyWait = null; if (parakeetPendingRequest) { parakeetPendingRequest.reject(new Error('Parakeet server killed')); parakeetPendingRequest = null; @@ -307,9 +326,12 @@ function ensureParakeetServer(): Promise { child.on('exit', (code: number | null) => { console.log(`[Parakeet] Server process exited with code ${code}`); + if (parakeetServerProcess !== child) return; parakeetServerReady = false; parakeetServerProcess = null; parakeetServerStarting = null; + parakeetServerReadyWait?.reject(new Error(`Parakeet server exited with code ${code}`)); + parakeetServerReadyWait = null; if (parakeetPendingRequest) { parakeetPendingRequest.reject(new Error(`Parakeet server exited with code ${code}`)); parakeetPendingRequest = null; @@ -317,9 +339,11 @@ function ensureParakeetServer(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { - parakeetServerBuffer += chunk.toString(); - const lines = parakeetServerBuffer.split('\n'); - parakeetServerBuffer = lines.pop() || ''; + if (parakeetServerProcess !== child) return; + const result = appendNativeHelperLineBuffer(parakeetServerBuffer, chunk); + parakeetServerBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('Parakeet'); + const lines = result.lines; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -327,6 +351,7 @@ function ensureParakeetServer(): Promise { const json = JSON.parse(trimmed); if (json.ready) { parakeetServerReady = true; + parakeetServerReadyWait?.markReady(); console.log('[Parakeet] Server ready (models loaded)'); continue; } @@ -344,25 +369,20 @@ function ensureParakeetServer(): Promise { }); // Wait for "ready" signal - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Parakeet server startup timed out (120s)')); - killParakeetServer(); - }, 120_000); - - const checkReady = setInterval(() => { - if (parakeetServerReady) { - clearInterval(checkReady); - clearTimeout(timeout); - resolve(); - } - if (!parakeetServerProcess || parakeetServerProcess.killed) { - clearInterval(checkReady); - clearTimeout(timeout); - reject(new Error('Parakeet server process died during startup')); - } - }, 50); + const readyWait = createNativeHelperReadinessWait({ + timeoutMs: 120_000, + timeoutMessage: 'Parakeet server startup timed out (120s)', + supersededMessage: 'Parakeet server startup superseded', + diedMessage: 'Parakeet server process died during startup', + isActive: () => parakeetServerProcess === child, + isKilled: () => child.killed, + kill: () => killParakeetServer(child), }); + parakeetServerReadyWait = readyWait; + await readyWait.promise; + if (parakeetServerReadyWait === readyWait) { + parakeetServerReadyWait = null; + } parakeetServerStarting = null; })(); @@ -619,18 +639,22 @@ let qwen3ServerStarting: Promise | null = null; let qwen3ServerBuffer = ''; type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void }; let qwen3PendingRequest: PendingQwen3Request | null = null; +let qwen3ServerReadyWait: NativeHelperReadinessWait | 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 = ''; + qwen3ServerReadyWait?.reject(new Error('Qwen3 server killed')); + qwen3ServerReadyWait = null; if (qwen3PendingRequest) { qwen3PendingRequest.reject(new Error('Qwen3 server killed')); qwen3PendingRequest = null; @@ -662,9 +686,12 @@ function ensureQwen3Server(): Promise { child.on('exit', (code: number | null) => { console.log(`[Qwen3] Server process exited with code ${code}`); + if (qwen3ServerProcess !== child) return; qwen3ServerReady = false; qwen3ServerProcess = null; qwen3ServerStarting = null; + qwen3ServerReadyWait?.reject(new Error(`Qwen3 server exited with code ${code}`)); + qwen3ServerReadyWait = null; if (qwen3PendingRequest) { qwen3PendingRequest.reject(new Error(`Qwen3 server exited with code ${code}`)); qwen3PendingRequest = null; @@ -672,9 +699,11 @@ function ensureQwen3Server(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { - qwen3ServerBuffer += chunk.toString(); - const lines = qwen3ServerBuffer.split('\n'); - qwen3ServerBuffer = lines.pop() || ''; + if (qwen3ServerProcess !== child) return; + const result = appendNativeHelperLineBuffer(qwen3ServerBuffer, chunk); + qwen3ServerBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('Qwen3'); + const lines = result.lines; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -682,6 +711,7 @@ function ensureQwen3Server(): Promise { const json = JSON.parse(trimmed); if (json.ready) { qwen3ServerReady = true; + qwen3ServerReadyWait?.markReady(); console.log('[Qwen3] Server ready (models loaded)'); continue; } @@ -698,25 +728,20 @@ function ensureQwen3Server(): Promise { } }); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Qwen3 server startup timed out (120s)')); - killQwen3Server(); - }, 120_000); - - const checkReady = setInterval(() => { - if (qwen3ServerReady) { - clearInterval(checkReady); - clearTimeout(timeout); - resolve(); - } - if (!qwen3ServerProcess || qwen3ServerProcess.killed) { - clearInterval(checkReady); - clearTimeout(timeout); - reject(new Error('Qwen3 server process died during startup')); - } - }, 50); + const readyWait = createNativeHelperReadinessWait({ + timeoutMs: 120_000, + timeoutMessage: 'Qwen3 server startup timed out (120s)', + supersededMessage: 'Qwen3 server startup superseded', + diedMessage: 'Qwen3 server process died during startup', + isActive: () => qwen3ServerProcess === child, + isKilled: () => child.killed, + kill: () => killQwen3Server(child), }); + qwen3ServerReadyWait = readyWait; + await readyWait.promise; + if (qwen3ServerReadyWait === readyWait) { + qwen3ServerReadyWait = null; + } qwen3ServerStarting = null; })(); @@ -1153,18 +1178,22 @@ let whisperCppServerStarting: Promise | null = null; let whisperCppServerBuffer = ''; type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let whisperCppPendingRequest: PendingWhisperCppRequest | null = null; +let whisperCppServerReadyWait: NativeHelperReadinessWait | 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 = ''; + whisperCppServerReadyWait?.reject(new Error('Whisper.cpp server killed')); + whisperCppServerReadyWait = null; if (whisperCppPendingRequest) { whisperCppPendingRequest.reject(new Error('Whisper.cpp server killed')); whisperCppPendingRequest = null; @@ -1193,9 +1222,12 @@ function ensureWhisperCppServer(): Promise { child.on('exit', (code: number | null) => { console.log(`[Whisper][whisper.cpp] Server process exited with code ${code}`); + if (whisperCppServerProcess !== child) return; whisperCppServerReady = false; whisperCppServerProcess = null; whisperCppServerStarting = null; + whisperCppServerReadyWait?.reject(new Error(`Whisper.cpp server exited with code ${code}`)); + whisperCppServerReadyWait = null; if (whisperCppPendingRequest) { whisperCppPendingRequest.reject(new Error(`Whisper.cpp server exited with code ${code}`)); whisperCppPendingRequest = null; @@ -1203,9 +1235,11 @@ function ensureWhisperCppServer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { - whisperCppServerBuffer += chunk.toString(); - const lines = whisperCppServerBuffer.split('\n'); - whisperCppServerBuffer = lines.pop() || ''; + if (whisperCppServerProcess !== child) return; + const result = appendNativeHelperLineBuffer(whisperCppServerBuffer, chunk); + whisperCppServerBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('Whisper.cpp'); + const lines = result.lines; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -1213,6 +1247,7 @@ function ensureWhisperCppServer(): Promise { const json = JSON.parse(trimmed); if (json.ready) { whisperCppServerReady = true; + whisperCppServerReadyWait?.markReady(); console.log('[Whisper][whisper.cpp] Server ready (model loaded)'); continue; } @@ -1235,25 +1270,20 @@ function ensureWhisperCppServer(): Promise { }); // Wait for "ready" signal - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Whisper.cpp server startup timed out (60s)')); - killWhisperCppServer(); - }, 60_000); - - const checkReady = setInterval(() => { - if (whisperCppServerReady) { - clearInterval(checkReady); - clearTimeout(timeout); - resolve(); - } - if (!whisperCppServerProcess || whisperCppServerProcess.killed) { - clearInterval(checkReady); - clearTimeout(timeout); - reject(new Error('Whisper.cpp server process died during startup')); - } - }, 50); + const readyWait = createNativeHelperReadinessWait({ + timeoutMs: 60_000, + timeoutMessage: 'Whisper.cpp server startup timed out (60s)', + supersededMessage: 'Whisper.cpp server startup superseded', + diedMessage: 'Whisper.cpp server process died during startup', + isActive: () => whisperCppServerProcess === child, + isKilled: () => child.killed, + kill: () => killWhisperCppServer(child), }); + whisperCppServerReadyWait = readyWait; + await readyWait.promise; + if (whisperCppServerReadyWait === readyWait) { + whisperCppServerReadyWait = null; + } whisperCppServerStarting = null; })(); @@ -1412,6 +1442,7 @@ let audioCapturerBuffer = ''; let audioCapturerRecording = false; type PendingAudioCapturerRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let audioCapturerPendingRequest: PendingAudioCapturerRequest | null = null; +let audioCapturerReadyWait: NativeHelperReadinessWait | null = null; type AudioCapturerMeter = { average: number; peak: number }; let audioCapturerMeter: AudioCapturerMeter = { average: 0, peak: 0 }; @@ -1421,18 +1452,21 @@ 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 = ''; audioCapturerRecording = false; + audioCapturerReadyWait?.reject(new Error('Audio capturer killed')); + audioCapturerReadyWait = null; if (audioCapturerPendingRequest) { audioCapturerPendingRequest.reject(new Error('Audio capturer killed')); audioCapturerPendingRequest = null; @@ -1512,10 +1546,13 @@ function warmAudioCapturer(): Promise { child.on('exit', (code: number | null) => { console.log(`[AudioCapturer] Process exited with code ${code}`); + if (audioCapturerProcess !== child) return; audioCapturerReady = false; audioCapturerProcess = null; audioCapturerStarting = null; audioCapturerRecording = false; + audioCapturerReadyWait?.reject(new Error(`Audio capturer exited with code ${code}`)); + audioCapturerReadyWait = null; if (audioCapturerPendingRequest) { audioCapturerPendingRequest.reject(new Error(`Audio capturer exited with code ${code}`)); audioCapturerPendingRequest = null; @@ -1523,9 +1560,11 @@ function warmAudioCapturer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { - audioCapturerBuffer += chunk.toString(); - const lines = audioCapturerBuffer.split('\n'); - audioCapturerBuffer = lines.pop() || ''; + if (audioCapturerProcess !== child) return; + const result = appendNativeHelperLineBuffer(audioCapturerBuffer, chunk); + audioCapturerBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('AudioCapturer'); + const lines = result.lines; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -1534,6 +1573,7 @@ function warmAudioCapturer(): Promise { if (json.ready) { audioCapturerReady = true; + audioCapturerReadyWait?.markReady(); console.log('[AudioCapturer] Engine ready (mic hot)'); continue; } @@ -1579,25 +1619,20 @@ function warmAudioCapturer(): Promise { child.stdin.write(JSON.stringify({ command: 'warmup' }) + '\n'); // Wait for "ready" signal - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Audio capturer warmup timed out (30s)')); - killAudioCapturer(); - }, 30_000); - - const checkReady = setInterval(() => { - if (audioCapturerReady) { - clearInterval(checkReady); - clearTimeout(timeout); - resolve(); - } - if (!audioCapturerProcess || audioCapturerProcess.killed) { - clearInterval(checkReady); - clearTimeout(timeout); - reject(new Error('Audio capturer process died during warmup')); - } - }, 50); + const readyWait = createNativeHelperReadinessWait({ + timeoutMs: 30_000, + timeoutMessage: 'Audio capturer warmup timed out (30s)', + supersededMessage: 'Audio capturer warmup superseded', + diedMessage: 'Audio capturer process died during warmup', + isActive: () => audioCapturerProcess === child, + isKilled: () => child.killed, + kill: () => killAudioCapturer(child), }); + audioCapturerReadyWait = readyWait; + await readyWait.promise; + if (audioCapturerReadyWait === readyWait) { + audioCapturerReadyWait = null; + } audioCapturerStarting = null; })(); @@ -5574,16 +5609,19 @@ async function ensureSpeechRecognitionAccess(prompt = true): Promise { - stdoutBuffer += String(chunk || ''); - const lines = stdoutBuffer.split('\n'); - stdoutBuffer = lines.pop() || ''; + const result = appendNativeHelperLineBuffer(stdoutBuffer, chunk); + stdoutBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('SpeechRecognitionPermission'); + const lines = result.lines; for (const line of lines) { parseLine(line); } }); proc.stderr.on('data', (chunk: Buffer | string) => { - stderrBuffer += String(chunk || ''); + const result = appendNativeHelperTextBuffer(stderrBuffer, chunk); + stderrBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('SpeechRecognitionPermission stderr'); }); proc.on('error', (error: Error) => { @@ -5716,6 +5754,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; @@ -5756,7 +5831,6 @@ function transcribeAudioWithElevenLabs(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5769,14 +5843,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.')); @@ -5801,12 +5872,11 @@ function transcribeAudioWithElevenLabs(opts: { } resolve(text); } - }); + }).catch(reject); } ); req.on('error', reject); - req.write(body); - req.end(); + writeBufferedRequestParts(req, parts); } catch (error) { reject(error); } @@ -5843,7 +5913,6 @@ function transcribeAudioWithMistralVoxtral(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5856,14 +5925,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; @@ -5890,15 +5956,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); } @@ -5929,27 +5994,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(); }); } ); @@ -15250,6 +15326,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 ( @@ -15259,6 +15344,7 @@ app.whenReady().then(async () => { method?: string; headers?: Record; body?: string; + requestId?: string; } ) => { const http = require('http'); @@ -15276,17 +15362,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, @@ -15294,87 +15402,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); + } } ); @@ -15395,7 +15529,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', @@ -15434,9 +15619,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 {} }; @@ -15468,7 +15655,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 }); @@ -15477,7 +15664,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 }); @@ -15490,7 +15677,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)) { @@ -15501,20 +15688,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); }); } @@ -15589,17 +15763,60 @@ 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, requestId?: string) => { const https = require('https'); const http = require('http'); const { execFile } = require('child_process'); const REQUEST_TIMEOUT_MS = 30_000; + const binaryRequestId = typeof requestId === 'string' ? requestId : ''; + let canceled = false; + let activeCancel: (() => void) | undefined; + const cancelError = () => { + const err = new Error('Request canceled'); + err.name = 'AbortError'; + return err; + }; + const throwIfCanceled = () => { + if (canceled) throw cancelError(); + }; + const setActiveCancel = (cancel: () => void) => { + activeCancel = cancel; + if (!binaryRequestId) return; + activeHttpRequests.set(binaryRequestId, () => { + canceled = true; + try { + activeCancel?.(); + } catch {} + }); + }; + + if (binaryRequestId) { + setActiveCancel(() => {}); + } const downloadUrl = async (targetUrl: string, redirectCount = 0): Promise => { if (redirectCount > 10) throw new Error('Too many redirects'); + throwIfCanceled(); const parsed = new URL(targetUrl); return new Promise((resolve, reject) => { + let settled = false; + let chunks: Buffer[] = []; + const cleanupChunks = () => { + chunks = []; + }; + const settleResolve = (value: Uint8Array) => { + if (settled) return; + settled = true; + cleanupChunks(); + resolve(value); + }; + const settleReject = (err: Error) => { + if (settled) return; + settled = true; + cleanupChunks(); + reject(err); + }; const client = parsed.protocol === 'https:' ? https : http; const req = client.get( parsed.toString(), @@ -15610,28 +15827,53 @@ app.whenReady().then(async () => { }, }, (res: any) => { + if (canceled) { + res.resume(); + settleReject(cancelError()); + 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); + downloadUrl(redirectUrl, redirectCount + 1).then(settleResolve, settleReject); return; } if (res.statusCode !== 200) { res.resume(); - reject(new Error(`HTTP ${res.statusCode}`)); + settleReject(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) { + settleReject(cancelError()); + return; + } + settleResolve(new Uint8Array(Buffer.concat(chunks))); + }); + res.on('error', (err: Error) => { + settleReject(canceled ? cancelError() : err); + }); } ); + setActiveCancel(() => { + if (settled) return; + try { + req.destroy(cancelError()); + } catch { + req.destroy(); + } + settleReject(cancelError()); + }); 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) => { + settleReject(canceled ? cancelError() : err); + }); }); }; @@ -15639,8 +15881,20 @@ app.whenReady().then(async () => { try { return await downloadUrl(url); } catch (primaryErr: any) { + throwIfCanceled(); const curlOutput = await new Promise((resolve, reject) => { - execFile( + let settled = false; + const settleResolve = (value: Uint8Array) => { + if (settled) return; + settled = true; + resolve(value); + }; + const settleReject = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + const child = execFile( '/usr/bin/curl', [ '-fsSL', @@ -15652,24 +15906,41 @@ app.whenReady().then(async () => { ], { encoding: null, maxBuffer: 100 * 1024 * 1024 }, (err: Error | null, stdout: Buffer, stderr: Buffer | string) => { + if (canceled) { + settleReject(cancelError()); + return; + } if (err) { - const stderrText = typeof stderr === 'string' ? stderr : String(stderr || ''); - reject( + 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); + settleReject( new Error( `HTTP download failed (${primaryErr?.message || 'unknown'}) and curl fallback failed (${stderrText || err.message})` ) ); return; } - resolve(new Uint8Array(stdout)); + settleResolve(new Uint8Array(stdout)); } ); + setActiveCancel(() => { + if (settled) return; + try { child.kill('SIGTERM'); } catch {} + settleReject(cancelError()); + }); }); return curlOutput; } }; - return downloadWithCurlFallback(); + try { + return await downloadWithCurlFallback(); + } finally { + if (binaryRequestId) activeHttpRequests.delete(binaryRequestId); + } }); // Write raw binary data to a real file path (extensions use this for CLI tool downloads) @@ -16348,12 +16619,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 @@ -16371,10 +16640,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. @@ -18210,7 +18478,7 @@ if let tiff = image?.tiffRepresentation { requestId, error: `HTTP ${res.statusCode}: ${errBody.slice(0, 200)}`, }); - activeAIRequests.delete(requestId); + finishPullRequest(); }); return; } @@ -18254,7 +18522,7 @@ if let tiff = image?.tiffRepresentation { if (!controller.signal.aborted) { event.sender.send('ollama-pull-done', { requestId }); } - activeAIRequests.delete(requestId); + finishPullRequest(); }); } ); @@ -18266,16 +18534,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(); + activeAIRequests.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(); @@ -18712,7 +18998,9 @@ if let tiff = image?.tiffRepresentation { let stderrBuffer = ''; child.stdout?.on('data', (chunk: Buffer) => { - stdoutBuffer += chunk.toString('utf8'); + const result = appendNativeHelperTextBuffer(stdoutBuffer, chunk); + stdoutBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('KeyboardLock stdout'); if (!settled && stdoutBuffer.includes('ready')) { settled = true; resolve({ ok: true }); @@ -18720,7 +19008,9 @@ if let tiff = image?.tiffRepresentation { }); child.stderr?.on('data', (chunk: Buffer) => { - stderrBuffer += chunk.toString('utf8'); + const result = appendNativeHelperTextBuffer(stderrBuffer, chunk); + stderrBuffer = result.buffer; + if (result.truncated) warnNativeHelperLineBufferTruncated('KeyboardLock stderr'); }); child.on('exit', (code: number | null) => { diff --git a/src/main/native-helper-lifecycle.ts b/src/main/native-helper-lifecycle.ts new file mode 100644 index 00000000..a1571686 --- /dev/null +++ b/src/main/native-helper-lifecycle.ts @@ -0,0 +1,116 @@ +export const NATIVE_HELPER_LINE_BUFFER_MAX_CHARS = 256 * 1024; + +export type NativeHelperLineBufferResult = { + buffer: string; + lines: string[]; + truncated: boolean; +}; + +export function appendNativeHelperTextBuffer( + buffer: string, + chunk: Buffer | string, + maxChars = NATIVE_HELPER_LINE_BUFFER_MAX_CHARS +): { buffer: string; truncated: boolean } { + const combined = buffer + chunk.toString(); + if (combined.length <= maxChars) { + return { buffer: combined, truncated: false }; + } + return { buffer: combined.slice(-maxChars), truncated: true }; +} + +export function appendNativeHelperLineBuffer( + buffer: string, + chunk: Buffer | string, + maxChars = NATIVE_HELPER_LINE_BUFFER_MAX_CHARS +): NativeHelperLineBufferResult { + const combined = buffer + chunk.toString(); + const rawLines = combined.split('\n'); + let nextBuffer = rawLines.pop() ?? ''; + let truncated = false; + const lines: string[] = []; + + for (const line of rawLines) { + lines.push(line); + } + + if (nextBuffer.length > maxChars) { + nextBuffer = nextBuffer.slice(-maxChars); + truncated = true; + } + + return { buffer: nextBuffer, lines, truncated }; +} + +export type NativeHelperReadinessWait = { + promise: Promise; + markReady: () => void; + reject: (error: Error) => void; +}; + +export function createNativeHelperReadinessWait(options: { + timeoutMs: number; + timeoutMessage: string; + supersededMessage: string; + diedMessage: string; + isActive: () => boolean; + isKilled: () => boolean; + kill: () => void; +}): NativeHelperReadinessWait { + let settled = false; + let timeout: ReturnType | null = null; + let resolvePromise: (() => void) | null = null; + let rejectPromise: ((error: Error) => void) | null = null; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + resolvePromise = null; + rejectPromise = null; + }; + + const settle = (error?: Error) => { + if (settled) return; + settled = true; + const resolve = resolvePromise; + const reject = rejectPromise; + cleanup(); + if (error) { + reject?.(error); + } else { + resolve?.(); + } + }; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + timeout = setTimeout(() => { + if (!options.isActive()) { + settle(new Error(options.supersededMessage)); + return; + } + settle(new Error(options.timeoutMessage)); + options.kill(); + }, options.timeoutMs); + }); + + return { + promise, + markReady: () => { + if (!options.isActive()) { + settle(new Error(options.supersededMessage)); + return; + } + if (options.isKilled()) { + settle(new Error(options.diedMessage)); + return; + } + settle(); + }, + reject: (error: Error) => { + settle(error); + }, + }; +} diff --git a/src/main/preload.ts b/src/main/preload.ts index eff106ef..7c4d82c0 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; @@ -462,9 +463,13 @@ const electronAPI = { url: string; }> => ipcRenderer.invoke('http-request', options), + cancelHttpRequest: (requestId: string): void => { + ipcRenderer.send('http-request-cancel', requestId); + }, + // 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, requestId?: string): Promise => + ipcRenderer.invoke('http-download-binary', url, requestId), // Write raw binary data to a file (used by extension download/install flows) fsWriteBinaryFile: (filePath: string, data: Uint8Array): Promise => diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index bf558af6..3cf7a01b 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -16,11 +16,22 @@ import { ArrowLeft, AlertTriangle } from 'lucide-react'; import * as RaycastAPI from './raycast-api'; import { NavigationContext, setExtensionContext, setGlobalNavigation, ExtensionContextType, ExtensionInfoReactContext } from './raycast-api'; import { withExtensionContext } from './raycast-api/context-scope-runtime'; +import { installExtensionFetchBridge } from './extension-fetch-bridge'; import { getCompiledExtensionWrapper } from './utils/extension-wrapper-cache'; // Also import @raycast/utils stubs from our shim import * as RaycastUtils from './raycast-api'; +let extensionRuntimeRequestSeq = 0; + +function createExtensionRuntimeRequestId(prefix: string): string { + const randomId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + return `${prefix}:${Date.now()}:${++extensionRuntimeRequestSeq}:${randomId}`; +} + // ─── React Module for Extensions ──────────────────────────────────── // Extensions MUST use the exact same React instance as the host app. // @@ -2085,7 +2096,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 = {}; @@ -2100,7 +2112,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, @@ -2179,7 +2191,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[] = []; @@ -2202,7 +2215,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; @@ -2290,7 +2303,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] : {}; @@ -2301,7 +2315,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 } @@ -2348,6 +2373,7 @@ const childProcessStub = { cp.emit('exit', 1, null); }; const queueOrProcess = (event: PendingSpawnEvent) => { + if (disposedByLifecycle) return; if (pid === null) { pendingEvents.push(event); return; @@ -2401,8 +2427,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 }> = []; @@ -2443,10 +2480,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)); @@ -2529,6 +2571,16 @@ const childProcessStub = { }, }; +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), @@ -3121,14 +3173,46 @@ 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'); + if (e?.name) err.name = e.name; err.isAxiosError = true; throw err; } @@ -3330,9 +3414,15 @@ function isNodeBuiltinRequest(name: string): boolean { return false; } +const SUPERCMD_BUILTIN_FACADE_MODULES = new Set([ + 'fs', + 'fs/promises', + 'child_process', +]); + function shouldUseSuperCmdBuiltinFacade(name: string): boolean { const normalized = name.startsWith('node:') ? name.slice(5) : name; - return normalized === 'fs' || normalized === 'fs/promises' || normalized === 'child_process'; + return SUPERCMD_BUILTIN_FACADE_MODULES.has(normalized); } const superCmdBuiltinFacadeCache = new Map(); @@ -3597,145 +3687,502 @@ 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); + installExtensionFetchBridge(g); +} + +interface TrackedChildProcess { + cleanup: () => void; + kill: () => void; + getPid?: () => number | null; +} + +type ExtensionTimerHandle = any; + +interface TrackedEventListener { + target: EventTarget; + type: string; + listener: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; + capture: boolean; +} + +/** + * 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; + 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(), + intervalClearers: new Map(), + timeoutClearers: new Map(), + rafClearers: new Map(), + eventListeners: new Set(), + childProcesses: new Set(), + }; +} + +export function clearTimerRegistry(registry: TimerRegistry): void { + 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; + } } - 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); - }; + return null; +} - 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); - } +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), + }); +} - // 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); - } +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); + } + }); +} - const method = (init?.method || input?.method || 'GET').toUpperCase(); - const headers = { - ...toHeadersObject(input?.headers), - ...toHeadersObject(init?.headers), - }; - const body = await normalizeBody(requestBody); +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; +} - 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 }); +function getWindowPeer(hostWindow: any, scopedWindow: any, prop: 'top' | 'parent' | 'opener'): any { + try { + const value = hostWindow?.[prop]; + return value === hostWindow ? scopedWindow : value; + } catch { + return scopedWindow; + } +} - 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}`); +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 = {}; + + // Extension code gets scoped window/document proxies so timer and listener + // APIs can be registered for lifecycle cleanup while ordinary host reads, + // writes, enumeration, and method calls continue to hit the real objects. + 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(); } - 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); + 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); + }; } - // 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 || {}, - }); + 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); + }; + } - try { - Object.defineProperty(response, 'url', { value: ipcRes.url || url }); - } catch {} + 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); + }, + }); +} - return response; +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, + }; +} + +function isExtensionTimerBuiltinRequest(name: string): boolean { + const normalized = name.startsWith('node:') ? name.slice(5) : name; + return normalized === 'timers' || normalized === 'timers/promises'; +} - g.__SUPERCMD_FETCH_PATCHED = true; +function createAbortError(): Error { + if (typeof DOMException === 'function') { + return new DOMException('The operation was aborted.', 'AbortError') as any; } + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; } -/** - * 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. - */ -export interface TimerRegistry { - intervals: Set; - timeouts: Set; - rafs: Set; +function createExtensionTimersPromisesFacade(lifecycleScope: ReturnType): any { + const wait = (ms?: number, value?: any, options?: { signal?: AbortSignal }) => { + const signal = options?.signal; + if (signal?.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + let id: ExtensionTimerHandle | undefined; + const cleanup = () => { + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + }; + const onAbort = signal + ? () => { + lifecycleScope.clearTimeout(id); + cleanup(); + reject(createAbortError()); + } + : undefined; + + if (signal && onAbort) signal.addEventListener('abort', onAbort, { once: true }); + id = lifecycleScope.setTimeout(() => { + cleanup(); + resolve(value); + }, ms as any); + }); + }; + + const waitImmediate = (value?: any, options?: { signal?: AbortSignal }) => { + const signal = options?.signal; + if (signal?.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + let id: ExtensionTimerHandle | undefined; + const cleanup = () => { + if (signal && onAbort) signal.removeEventListener('abort', onAbort); + }; + const onAbort = signal + ? () => { + lifecycleScope.clearImmediate(id); + cleanup(); + reject(createAbortError()); + } + : undefined; + + if (signal && onAbort) signal.addEventListener('abort', onAbort, { once: true }); + id = lifecycleScope.setImmediate(() => { + cleanup(); + resolve(value); + }); + }); + }; + + const interval = async function* (ms?: number, value?: any, options?: { signal?: AbortSignal }) { + while (!options?.signal?.aborted) { + yield await wait(ms, value, options); + } + throw createAbortError(); + }; + + return { + setTimeout: wait, + setInterval: interval, + setImmediate: waitImmediate, + scheduler: { wait: (ms?: number, options?: { signal?: AbortSignal }) => wait(ms, undefined, options) }, + }; } -export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; +function createExtensionTimerBuiltinFacade( + name: string, + lifecycleScope: ReturnType +): any { + const normalized = name.startsWith('node:') ? name.slice(5) : name; + if (normalized === 'timers/promises') { + return createExtensionTimersPromisesFacade(lifecycleScope); + } + return { + setTimeout: lifecycleScope.setTimeout, + clearTimeout: lifecycleScope.clearTimeout, + setInterval: lifecycleScope.setInterval, + clearInterval: lifecycleScope.clearInterval, + setImmediate: lifecycleScope.setImmediate, + clearImmediate: lifecycleScope.clearImmediate, + }; } -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)); - registry.intervals.clear(); - registry.timeouts.clear(); - registry.rafs.clear(); +export function trackChildProcess( + registry: TimerRegistry | undefined, + childProcess: TrackedChildProcess +): () => void { + if (!registry) return () => {}; + registry.childProcesses.add(childProcess); + return () => { + registry.childProcesses.delete(childProcess); + }; } /** @@ -3962,6 +4409,8 @@ function loadExtensionExport( // // IMPORTANT: We track React requires to verify the same instance is always returned. let reactRequireCount = 0; + let lifecycleScope: ReturnType; + const scopedTimerBuiltinFacades = new Map(); const fakeRequire: any = (name: string): any => { // Track all requires for debugging if (name === 'react' || name.startsWith('react/') || name === 'react-dom') { @@ -4099,8 +4548,21 @@ 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 (timerRegistry && isExtensionTimerBuiltinRequest(name)) { + const normalizedTimerBuiltinName = name.startsWith('node:') ? name.slice(5) : name; + let facade = scopedTimerBuiltinFacades.get(normalizedTimerBuiltinName); + if (!facade) { + facade = createExtensionTimerBuiltinFacade(normalizedTimerBuiltinName, lifecycleScope); + scopedTimerBuiltinFacades.set(normalizedTimerBuiltinName, facade); + } + return facade; + } if (shouldUseSuperCmdBuiltinFacade(name)) { - const facade = getSuperCmdBuiltinFacade(name); + const normalizedBuiltinName = name.startsWith('node:') ? name.slice(5) : name; + if (normalizedBuiltinName === 'child_process') { + return createExtensionChildProcessStub(timerRegistry); + } + const facade = getSuperCmdBuiltinFacade(name) || nodeBuiltinStubs[name] || nodeBuiltinStubs[`node:${name}`]; if (facade) return facade; } const realModule = tryRealNodeRequire(name); @@ -4265,44 +4727,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. + lifecycleScope = createExtensionLifecycleScope(timerRegistry); // Execute the CJS bundle in a function scope. // We pass all the standard CJS arguments plus `process`, `Buffer`, @@ -4335,16 +4763,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, ); diff --git a/src/renderer/src/extension-fetch-bridge.ts b/src/renderer/src/extension-fetch-bridge.ts new file mode 100644 index 00000000..083aea2b --- /dev/null +++ b/src/renderer/src/extension-fetch-bridge.ts @@ -0,0 +1,199 @@ +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, 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 }); + } catch (err) { + removeAbortListener(); + throw err; + } + + try { + 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 index e067fc35..deec326a 100644 --- a/src/renderer/src/hooks/backgroundRefreshTimers.ts +++ b/src/renderer/src/hooks/backgroundRefreshTimers.ts @@ -27,6 +27,22 @@ export interface BackgroundRefreshTimerReconcileOptions { clearTimer: (timerId: TTimerId) => void; } +export type BackgroundRefreshTick = () => Promise | void; + +export function createInFlightBackgroundRefreshTick(runTick: BackgroundRefreshTick): () => void { + let inFlight = false; + + return () => { + if (inFlight) return; + + inFlight = true; + void Promise.resolve() + .then(runTick) + .finally(() => { + inFlight = false; + }); + }; +} export function parseExtensionCommandPath(pathValue: string): { extName: string; cmdName: string } | null { const rawPath = String(pathValue || '').trim(); const separatorIndex = rawPath.indexOf('/'); diff --git a/src/renderer/src/hooks/useBackgroundRefresh.ts b/src/renderer/src/hooks/useBackgroundRefresh.ts index 013a2903..b4e81444 100644 --- a/src/renderer/src/hooks/useBackgroundRefresh.ts +++ b/src/renderer/src/hooks/useBackgroundRefresh.ts @@ -17,6 +17,7 @@ import type { CommandInfo } from '../../types/electron'; import { parseIntervalToMs } from '../utils/command-helpers'; import { clearBackgroundRefreshTimers, + createInFlightBackgroundRefreshTick, getBackgroundRefreshTimerDescriptors, reconcileBackgroundRefreshTimers, type BackgroundRefreshTimerEntry, @@ -57,7 +58,7 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand if (descriptor.kind === 'extension') { const { extName, cmdName } = descriptor.extensionCommand!; - return window.setInterval(async () => { + return window.setInterval(createInFlightBackgroundRefreshTick(async () => { try { const result = await window.electron.runExtension(extName, cmdName); if (!result || !result.code) return; @@ -97,10 +98,10 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand } catch (error) { console.error('[BackgroundRefresh] Failed to run command:', cmd.id, error); } - }, descriptor.intervalMs); + }), descriptor.intervalMs); } - return window.setInterval(async () => { + return window.setInterval(createInFlightBackgroundRefreshTick(async () => { try { const storedArgs = readJsonObject(getScriptCmdArgsKey(cmd.id)); const missingArgs = getMissingRequiredScriptArguments(cmd, storedArgs); @@ -116,7 +117,7 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand } catch (error) { console.error('[BackgroundRefresh] Failed to run script command:', cmd.id, error); } - }, descriptor.intervalMs); + }), descriptor.intervalMs); }, []); useEffect(() => { 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/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..dcbaccd5 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,19 +4,35 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; export function useGridRegistry() { const registryRef = 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(); + lastSnapshotRef.current = ''; + }; + }, []); + const scheduleRegistryUpdate = 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 snapshot = Array.from(registryRef.current.values()) .map((entry) => { @@ -35,6 +51,7 @@ export function useGridRegistry() { const registryAPI = useMemo( () => ({ set(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { existing.props = data.props; @@ -46,6 +63,7 @@ export function useGridRegistry() { scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleRegistryUpdate(); diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..3965d254 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -4,7 +4,7 @@ * 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'; function getReactTypeName(type: any): string { @@ -45,13 +45,29 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s export function useListRegistry() { const registryRef = 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(); + lastSnapshotRef.current = ''; + }; + }, []); + const scheduleRegistryUpdate = 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 snapshot = Array.from(registryRef.current.values()).map((item) => { const actionType = item.props.actions?.type as any; @@ -78,6 +94,7 @@ export function useListRegistry() { 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) @@ -98,6 +115,7 @@ export function useListRegistry() { scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleRegistryUpdate(); diff --git a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx index d12ee5fe..93623440 100644 --- a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx +++ b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx @@ -3,7 +3,7 @@ * Purpose: MenuBarExtra parent component and native menu serialization/effects. */ -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { getMenuBarRuntimeDeps } from './menubar-runtime-config'; import { createMenuBarVisiblePayloadHashCache, @@ -35,6 +35,7 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const visiblePayloadHashCacheRef = useRef(createMenuBarVisiblePayloadHashCache()); @@ -44,28 +45,42 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin if (isMenuBar) initMenuBarClickListener(); }, [isMenuBar]); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + }; + }, []); + + const scheduleRegistryUpdate = useCallback(() => { + if (!mountedRef.current || pendingRef.current) return; + + pendingRef.current = true; + queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } + pendingRef.current = false; + setRegistryVersion((v) => v + 1); + }); + }, []); + const registryAPI = useMemo(() => ({ register: (item: MBItemRegistration) => { + if (!mountedRef.current) return; registryRef.current.set(item.id, item); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, unregister: (id: string) => { + if (!mountedRef.current) return; registryRef.current.delete(id); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, - }), []); + }), [scheduleRegistryUpdate]); useEffect(() => { if (!isMenuBar) return; diff --git a/src/renderer/src/utils/extension-wrapper-cache.ts b/src/renderer/src/utils/extension-wrapper-cache.ts index 7c4fe67e..1ba31f68 100644 --- a/src/renderer/src/utils/extension-wrapper-cache.ts +++ b/src/renderer/src/utils/extension-wrapper-cache.ts @@ -18,6 +18,8 @@ export const EXTENSION_WRAPPER_ARGUMENTS = [ 'clearTimeout', 'requestAnimationFrame', 'cancelAnimationFrame', + 'window', + 'document', 'navigator', '__scDynamicImport', ] as const; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 203a7699..58f1bffe 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -1040,6 +1040,7 @@ export interface ElectronAPI { method?: string; headers?: Record; body?: string; + requestId?: string; }) => Promise<{ status: number; statusText: string; @@ -1047,7 +1048,8 @@ export interface ElectronAPI { bodyText: string; url: string; }>; - httpDownloadBinary: (url: string) => Promise; + cancelHttpRequest: (requestId: string) => void; + httpDownloadBinary: (url: string, requestId?: string) => Promise; fsWriteBinaryFile: (filePath: string, data: Uint8Array) => Promise; execCommand: ( command: string,