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/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..987f2645 --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,217 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeChildProcessMockSource(childProcessKey) { + return ` + const mock = globalThis[${JSON.stringify(childProcessKey)}]; + + export function spawn(...args) { + return mock.spawn(...args); + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, + mockChildProcess = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const childProcessKey = `__supercmdScriptCommandChildProcess_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + const childProcess = { + spawn() { + throw new Error('Unexpected child_process.spawn call'); + }, + }; + globalThis[metricsKey] = metrics; + if (mockChildProcess) { + globalThis[childProcessKey] = childProcess; + } + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (mockChildProcess) { + plugins.push({ + name: 'supercmd-script-command-child-process-mock', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^child_process$/ }, () => ({ + path: 'child-process-mock', + namespace: 'supercmd-child-process', + })); + pluginBuild.onLoad({ filter: /^child-process-mock$/, namespace: 'supercmd-child-process' }, () => ({ + contents: makeChildProcessMockSource(childProcessKey), + loader: 'js', + })); + }, + }); + } + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + childProcess, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..888b16c4 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,48 @@ -// Import a self-contained TypeScript module from a test by transpiling it with -// esbuild on the fly. This lets the recovery tests run the *real* production -// source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. -// -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. +// Import a TypeScript module from a test by bundling it with esbuild on the fly. +// Tests may pass simple module stubs for Node built-ins such as `https`. -import { transform } from 'esbuild'; -import fs from 'node:fs'; +import { build } from 'esbuild'; +import path from 'node:path'; -export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +let importNonce = 0; + +export async function importTs(absPath, options = {}) { + const resolvedPath = path.resolve(absPath); + const result = await build({ + absWorkingDir: options.root || process.cwd(), + entryPoints: [resolvedPath], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: options.target || 'node20', + logLevel: 'silent', + plugins: [stubPlugin(options.stubs || {})], + }); + const code = result.outputFiles[0].text; + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(code).toString('base64'), + `#${encodeURIComponent(resolvedPath)}-${importNonce++}`, + ].join(''); return import(dataUrl); } + +function stubPlugin(stubs) { + const stubSpecifiers = new Set(Object.keys(stubs)); + return { + name: 'ts-import-stubs', + setup(esbuild) { + esbuild.onResolve({ filter: /.*/ }, (args) => { + if (stubSpecifiers.has(args.path)) { + return { path: args.path, namespace: 'ts-import-stub' }; + } + return null; + }); + esbuild.onLoad({ filter: /.*/, namespace: 'ts-import-stub' }, (args) => ({ + contents: stubs[args.path], + loader: 'js', + })); + }, + }; +} diff --git a/scripts/test-ai-provider-gemini-prompt-streaming.mjs b/scripts/test-ai-provider-gemini-prompt-streaming.mjs new file mode 100644 index 00000000..90bade89 --- /dev/null +++ b/scripts/test-ai-provider-gemini-prompt-streaming.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { setImmediate as waitImmediate } from 'node:timers/promises'; +import { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +export function request(options, callback) { + const harness = globalThis.__geminiHttpsHarness; + if (!harness) throw new Error('Gemini HTTPS harness is not installed'); + return harness.request(options, callback); +} +export default { request }; +`; + +const { streamAI } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { https: HTTPS_STUB }, +}); + +const GEMINI_CONFIG = { + enabled: true, + provider: 'gemini', + geminiApiKey: 'test-gemini-key', +}; + +function createGeminiHttpsHarness() { + const requests = []; + + return { + requests, + request(options, callback) { + const record = { + options, + body: '', + destroyed: false, + response: null, + responseBytesWritten: 0, + }; + + const req = new EventEmitter(); + req.write = (chunk) => { + record.body += chunk.toString(); + return true; + }; + req.end = () => { + const response = new PassThrough(); + response.statusCode = 200; + record.response = response; + callback(response); + }; + req.destroy = () => { + record.destroyed = true; + record.response?.destroy(); + }; + + requests.push(record); + return req; + }, + }; +} + +function installGeminiHarness() { + const harness = createGeminiHttpsHarness(); + globalThis.__geminiHttpsHarness = harness; + return harness; +} + +async function waitForRequestResponse(harness) { + for (let attempt = 0; attempt < 50; attempt += 1) { + const request = harness.requests[0]; + if (request?.response) return request; + await waitImmediate(); + } + assert.fail('timed out waiting for Gemini HTTPS request'); +} + +function geminiSseFrame(payload) { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function geminiTextFrame(parts) { + return geminiSseFrame({ + candidates: [{ content: { parts } }], + }); +} + +function writeResponseChunk(request, chunk) { + request.responseBytesWritten += Buffer.byteLength(chunk); + request.response.write(chunk); +} + +function endResponse(request, chunk = '') { + if (chunk) writeResponseChunk(request, chunk); + request.response.end(); +} + +async function collectGeminiPrompt(frames, options = {}) { + const harness = installGeminiHarness(); + const chunks = []; + const stream = streamAI(GEMINI_CONFIG, { + prompt: options.prompt || 'Write a concise answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.3, + systemPrompt: options.systemPrompt, + }); + + const collecting = (async () => { + for await (const chunk of stream) chunks.push(chunk); + return chunks; + })(); + + const request = await waitForRequestResponse(harness); + for (const frame of frames) writeResponseChunk(request, frame); + request.response.end(); + + return { + chunks: await collecting, + request, + }; +} + +function legacyGeminiResponseText(parsed) { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; + return parts.map((part) => (typeof part?.text === 'string' ? part.text : '')).join(''); +} + +test('Gemini prompt streaming uses SSE and yields before the response ends', async (t) => { + const harness = installGeminiHarness(); + const stream = streamAI(GEMINI_CONFIG, { + prompt: 'Stream this answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.4, + systemPrompt: 'Keep it short', + }); + const iterator = stream[Symbol.asyncIterator](); + + let firstSettled = false; + const firstRead = iterator.next().then((result) => { + firstSettled = true; + return result; + }); + + const request = await waitForRequestResponse(harness); + await waitImmediate(); + assert.equal(firstSettled, false, 'first read should wait for provider bytes'); + assert.match(request.options.path, /:streamGenerateContent\?alt=sse&key=test-gemini-key$/); + assert.doesNotMatch(request.options.path, /:generateContent\?/); + + const body = JSON.parse(request.body); + assert.deepEqual(body.contents, [{ role: 'user', parts: [{ text: 'Stream this answer' }] }]); + assert.deepEqual(body.systemInstruction, { parts: [{ text: 'Keep it short' }] }); + + const firstFrame = geminiTextFrame([{ text: 'Hello ' }]); + const secondFrame = geminiTextFrame([{ text: 'world' }]); + const finishFrame = geminiSseFrame({ candidates: [{ finishReason: 'STOP' }] }); + const totalSseBytes = Buffer.byteLength(firstFrame + secondFrame + finishFrame); + + writeResponseChunk(request, firstFrame); + const first = await firstRead; + assert.deepEqual(first, { done: false, value: 'Hello ' }); + assert.equal(request.response.readableEnded, false, 'first chunk should be visible before stream end'); + + const bytesBeforeFirstYield = request.responseBytesWritten; + const secondRead = iterator.next(); + writeResponseChunk(request, secondFrame); + assert.deepEqual(await secondRead, { done: false, value: 'world' }); + + const doneRead = iterator.next(); + endResponse(request, finishFrame); + assert.deepEqual(await doneRead, { done: true, value: undefined }); + + const legacyGenerateContentBody = JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'Hello ' }, { text: 'world' }] } }], + }); + + assert.ok(bytesBeforeFirstYield < totalSseBytes); + t.diagnostic(`legacy generateContent buffered bytes before first yield: ${Buffer.byteLength(legacyGenerateContentBody)}`); + t.diagnostic(`streamGenerateContent bytes before first yield: ${bytesBeforeFirstYield} of ${totalSseBytes}`); + t.diagnostic('streamGenerateContent first yield occurred before response end: true'); +}); + +test('Gemini prompt SSE extraction preserves final joined text', async () => { + const legacyShape = { + candidates: [{ + content: { + parts: [ + { text: 'Alpha ' }, + { text: 'beta' }, + { inlineData: { mimeType: 'text/plain' } }, + { text: ' gamma' }, + ], + }, + }], + }; + const frames = [ + geminiTextFrame([{ text: 'Alpha ' }, { text: 'beta' }, { inlineData: { mimeType: 'text/plain' } }]), + geminiTextFrame([{ text: ' gamma' }]), + ]; + + const { chunks } = await collectGeminiPrompt(frames); + + assert.deepEqual(chunks, ['Alpha beta', ' gamma']); + assert.equal(chunks.join(''), legacyGeminiResponseText(legacyShape)); +}); + +test('Gemini prompt streaming preserves finishReason no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ finishReason: 'SAFETY' }] }), + ]), + /Gemini returned no text \(SAFETY\)\./, + ); +}); + +test('Gemini prompt streaming preserves generic no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ content: { parts: [{ text: '' }] } }] }), + ]), + /Gemini returned no text\./, + ); +}); diff --git a/scripts/test-ai-provider-whisper-upload.mjs b/scripts/test-ai-provider-whisper-upload.mjs new file mode 100644 index 00000000..19f56b8f --- /dev/null +++ b/scripts/test-ai-provider-whisper-upload.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +module.exports = { + request: (...args) => globalThis.__supercmdWhisperHttps.request(...args), +}; +`; + +const { transcribeAudio } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { + https: HTTPS_STUB, + }, +}); + +test('transcribeAudio streams Whisper multipart upload bytes without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(8 * 1024 * 1024, 0x61); + const https = createFakeHttps({ responseBody: ' hello from whisper \n' }); + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'whisper-1', + language: 'en', + mimeType: 'audio/wav', + })); + + assert.equal(result, 'hello from whisper'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + language: 'en', + mimeType: 'audio/wav', + model: 'whisper-1', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + authorization: req.options.headers.Authorization, + contentLength: req.options.headers['Content-Length'], + }, + { + hostname: 'api.openai.com', + path: '/v1/audio/transcriptions', + method: 'POST', + authorization: 'Bearer test-api-key', + contentLength: expectedBody.length, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assert.equal(Math.max(...req.writes.map((chunk) => chunk.length)), audioBuffer.length); + assert.equal(concatCalls.length, 0, 'production upload path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"', 'name="language"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('transcribeAudio preserves no-language multipart ordering and default upload metadata', async () => { + const audioBuffer = Buffer.from('tiny-audio'); + const https = createFakeHttps({ responseBody: 'ok' }); + + await transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'gpt-4o-transcribe', + }); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + model: 'gpt-4o-transcribe', + }); + const actualBody = Buffer.concat(req.writes); + const actualText = actualBody.toString('utf8'); + + assert.equal(req.options.headers['Content-Length'], Buffer.concat(expectedParts).length); + assert.equal(actualBody.equals(Buffer.concat(expectedParts)), true); + assert.equal(req.writes.length, expectedParts.length); + assert.match(actualText, /filename="audio\.webm"/); + assert.match(actualText, /Content-Type: audio\/webm/); + assert.doesNotMatch(actualText, /name="language"/); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"']); +}); + +test('transcribeAudio preserves Whisper HTTP and request error handling', async () => { + { + createFakeHttps({ statusCode: 429, responseBody: 'rate limited by test' }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /Whisper API HTTP 429: rate limited by test/, + ); + } + + { + createFakeHttps({ requestError: new Error('socket failed') }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /socket failed/, + ); + } +}); + +test('transcribeAudio preserves pre-aborted request behavior', async () => { + const controller = new AbortController(); + controller.abort(); + const https = createFakeHttps(); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + signal: controller.signal, + }), + /Transcription aborted/, + ); + + assert.equal(https.requests.length, 1); + assert.equal(https.requests[0].destroyed, true); + assert.equal(https.requests[0].ended, false); + assert.equal(https.requests[0].writes.length, 0); +}); + +async function instrumentBufferConcat(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + const result = await fn(); + return { result, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +function createFakeHttps(options = {}) { + const requests = []; + const fakeHttps = { + requests, + request(requestOptions, onResponse) { + const req = new FakeClientRequest(requestOptions, onResponse, options); + requests.push(req); + return req; + }, + }; + + globalThis.__supercmdWhisperHttps = fakeHttps; + return fakeHttps; +} + +class FakeClientRequest extends EventEmitter { + constructor(options, onResponse, responseOptions) { + super(); + this.options = options; + this.onResponse = onResponse; + this.responseOptions = responseOptions; + this.destroyed = false; + this.ended = false; + this.writes = []; + } + + write(chunk) { + this.writes.push(chunk); + return true; + } + + end() { + this.ended = true; + queueMicrotask(() => { + if (this.responseOptions.requestError) { + this.emit('error', this.responseOptions.requestError); + return; + } + if (this.destroyed) return; + + const res = new EventEmitter(); + res.statusCode = this.responseOptions.statusCode ?? 200; + this.onResponse(res); + res.emit('data', this.responseOptions.responseBody ?? 'transcript'); + res.emit('end'); + }); + } + + destroy() { + this.destroyed = true; + } +} + +function extractBoundary(contentType) { + const match = /^multipart\/form-data; boundary=(.+)$/.exec(contentType); + assert.ok(match, `expected multipart content type, got ${contentType}`); + return match[1]; +} + +function buildExpectedMultipartParts({ audioBuffer, boundary, language, mimeType, model }) { + const uploadMeta = resolveExpectedUploadMeta(mimeType); + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${uploadMeta.filename}"\r\nContent-Type: ${uploadMeta.contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model}\r\n`), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\ntext\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function resolveExpectedUploadMeta(mimeType) { + const normalized = String(mimeType || '').toLowerCase(); + if (normalized.includes('wav')) return { filename: 'audio.wav', contentType: 'audio/wav' }; + if (normalized.includes('mpeg') || normalized.includes('mp3')) return { filename: 'audio.mp3', contentType: 'audio/mpeg' }; + if (normalized.includes('mp4') || normalized.includes('m4a')) return { filename: 'audio.m4a', contentType: 'audio/mp4' }; + if (normalized.includes('ogg') || normalized.includes('oga')) return { filename: 'audio.ogg', contentType: 'audio/ogg' }; + if (normalized.includes('flac')) return { filename: 'audio.flac', contentType: 'audio/flac' }; + return { filename: 'audio.webm', contentType: 'audio/webm' }; +} + +function assertMultipartOrder(body, markers) { + const text = body.toString('utf8'); + let previous = -1; + + for (const marker of markers) { + const index = text.indexOf(marker); + assert.notEqual(index, -1, `expected multipart marker ${marker}`); + assert.ok(index > previous, `expected ${marker} after previous multipart field`); + previous = index; + } +} diff --git a/scripts/test-extension-lifecycle-sandbox.mjs b/scripts/test-extension-lifecycle-sandbox.mjs new file mode 100644 index 00000000..ccd152b0 --- /dev/null +++ b/scripts/test-extension-lifecycle-sandbox.mjs @@ -0,0 +1,608 @@ +#!/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'); +const extensionWrapperCachePath = path.join(root, 'src/renderer/src/utils/extension-wrapper-cache.ts'); + +function captureFromOptions(options) { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function createFakeEventTarget(label) { + const listenerIds = new WeakMap(); + const listeners = new Map(); + let nextListenerId = 1; + + function listenerId(listener) { + if (!listener || (typeof listener !== 'function' && typeof listener !== 'object')) { + return 'null'; + } + let id = listenerIds.get(listener); + if (!id) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + return id; + } + + function key(type, listener, options) { + return `${String(type)}:${captureFromOptions(options)}:${listenerId(listener)}`; + } + + return { + label, + listeners, + addEventListener(type, listener, options) { + if (!listener) return; + listeners.set(key(type, listener, options), { type, listener, options }); + }, + removeEventListener(type, listener, options) { + listeners.delete(key(type, listener, options)); + }, + }; +} + +function createHostWindow() { + let nextTimerId = 1; + const intervals = new Set(); + const timeouts = new Map(); + const rafs = new Map(); + const windowTarget = createFakeEventTarget('window'); + const documentTarget = createFakeEventTarget('document'); + + const hostWindow = { + document: documentTarget, + navigator: { userAgent: 'SuperCmd lifecycle harness' }, + location: { href: 'supercmd://lifecycle-harness' }, + electron: { marker: 'electron-facade' }, + setInterval() { + const id = nextTimerId++; + intervals.add(id); + return id; + }, + clearInterval(id) { + intervals.delete(id); + }, + setTimeout(handler, _timeout, ...args) { + const id = nextTimerId++; + timeouts.set(id, { handler, args }); + return id; + }, + clearTimeout(id) { + timeouts.delete(id); + }, + requestAnimationFrame(callback) { + const id = nextTimerId++; + rafs.set(id, { callback }); + return id; + }, + cancelAnimationFrame(id) { + rafs.delete(id); + }, + addEventListener: windowTarget.addEventListener, + removeEventListener: windowTarget.removeEventListener, + }; + + hostWindow.window = hostWindow; + hostWindow.self = hostWindow; + hostWindow.globalThis = hostWindow; + hostWindow.global = hostWindow; + hostWindow.top = hostWindow; + hostWindow.parent = hostWindow; + documentTarget.defaultView = hostWindow; + + return { + hostWindow, + hostDocument: documentTarget, + snapshot() { + return { + intervals: intervals.size, + timeouts: timeouts.size, + rafs: rafs.size, + windowListeners: windowTarget.listeners.size, + documentListeners: documentTarget.listeners.size, + total: + intervals.size + + timeouts.size + + rafs.size + + windowTarget.listeners.size + + documentTarget.listeners.size, + }; + }, + reset() { + intervals.clear(); + timeouts.clear(); + rafs.clear(); + windowTarget.listeners.clear(); + documentTarget.listeners.clear(); + }, + flushTimeouts() { + const pending = Array.from(timeouts.entries()); + for (const [id, { handler, args }] of pending) { + if (!timeouts.has(id)) continue; + timeouts.delete(id); + if (typeof handler === 'function') { + handler.call(hostWindow, ...args); + } + } + }, + flushRafs(timestamp = 16.7) { + const pending = Array.from(rafs.entries()); + for (const [id, { callback }] of pending) { + if (!rafs.has(id)) continue; + rafs.delete(id); + callback.call(hostWindow, timestamp); + } + }, + }; +} + +const host = createHostWindow(); +globalThis.window = host.hostWindow; +globalThis.document = host.hostDocument; +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, +}; + +function extractLifecycleSource() { + const source = fs.readFileSync(extensionViewPath, 'utf8'); + const start = source.indexOf('type ExtensionTimerHandle'); + const end = source.indexOf('/**\n * JS shim for the `swift:AppleReminders`', start); + assert.notEqual(start, -1, 'Could not locate lifecycle registry start marker'); + assert.notEqual(end, -1, 'Could not locate lifecycle registry end marker'); + return source + .slice(start, end) + .replace(/\bexport\s+(?=(interface|function)\b)/g, ''); +} + +function loadLifecycleHarness() { + const source = ` + ${extractLifecycleSource()} + globalThis.__extensionLifecycleHarness = { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, + }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.None, + target: ts.ScriptTarget.ES2022, + }, + fileName: extensionViewPath, + }); + const sandbox = { + console, + window: host.hostWindow, + document: host.hostDocument, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: extensionViewPath }); + return sandbox.__extensionLifecycleHarness; +} + +const { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, +} = loadLifecycleHarness(); + +const extensionCode = ` +const onResize = () => {}; +const onVisibilityChange = () => {}; +const intervalId = window.setInterval(() => {}, 60000); +window.addEventListener("resize", onResize); +window.document.addEventListener("visibilitychange", onVisibilityChange); + +exports.default = function LifecycleProbe() { + return { + intervalId, + compatibility: { + documentRead: Boolean(window.document) && window.document === document, + navigatorRead: window.navigator.userAgent, + locationRead: window.location.href, + electronRead: window.electron.marker, + globalWindowRead: globalThis.window === window, + defaultViewRead: document.defaultView === window + } + }; +}; +`; + +const EXTENSION_WRAPPER_ARGUMENTS = [ + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + 'process', + 'Buffer', + 'global', + 'globalThis', + 'window', + 'self', + 'document', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'navigator', + '__scDynamicImport', +]; + +function createBareTimerApi(registry) { + const setIntervalTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setInterval(callback, timeout, ...args); + registry.intervals.add(id); + registry.intervalClearers.set(id, host.hostWindow.clearInterval); + return id; + }; + const clearIntervalTracked = (id) => { + registry.intervals.delete(id); + registry.intervalClearers.delete(id); + host.hostWindow.clearInterval(id); + }; + const setTimeoutTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setTimeout(callback, timeout, ...args); + registry.timeouts.add(id); + registry.timeoutClearers.set(id, host.hostWindow.clearTimeout); + return id; + }; + const clearTimeoutTracked = (id) => { + registry.timeouts.delete(id); + registry.timeoutClearers.delete(id); + host.hostWindow.clearTimeout(id); + }; + const requestAnimationFrameTracked = (callback) => { + const id = host.hostWindow.requestAnimationFrame(callback); + registry.rafs.add(id); + registry.rafClearers.set(id, host.hostWindow.cancelAnimationFrame); + return id; + }; + const cancelAnimationFrameTracked = (id) => { + registry.rafs.delete(id); + registry.rafClearers.delete(id); + host.hostWindow.cancelAnimationFrame(id); + }; + return { + setImmediate: (callback, ...args) => setTimeoutTracked(() => callback(...args), 0), + clearImmediate: clearTimeoutTracked, + setInterval: setIntervalTracked, + clearInterval: clearIntervalTracked, + setTimeout: setTimeoutTracked, + clearTimeout: clearTimeoutTracked, + requestAnimationFrame: requestAnimationFrameTracked, + cancelAnimationFrame: cancelAnimationFrameTracked, + }; +} + +function runWrapper({ lifecycleScope, registry, scoped }) { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const bareTimerApi = scoped ? lifecycleScope : createBareTimerApi(registry); + const wrapperWindow = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapperDocument = scoped ? lifecycleScope.scopedDocument : host.hostDocument; + const globalObject = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, extensionCode); + + wrapper( + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalObject, + globalObject, + wrapperWindow, + wrapperWindow, + wrapperDocument, + bareTimerApi.setImmediate, + bareTimerApi.clearImmediate, + bareTimerApi.setInterval, + bareTimerApi.clearInterval, + bareTimerApi.setTimeout, + bareTimerApi.clearTimeout, + bareTimerApi.requestAnimationFrame, + bareTimerApi.cancelAnimationFrame, + undefined, + async () => ({}), + ); + + return fakeModule.exports.default(); +} + +test('extension lifecycle sandbox cleanup', async (t) => { + await t.test('production wrapper wires the lifecycle scope', () => { + const source = fs.readFileSync(extensionViewPath, 'utf8'); + const start = source.indexOf('function loadExtensionExport('); + const end = source.indexOf('// Get the default export', start); + assert.notEqual(start, -1, 'Could not locate loadExtensionExport start marker'); + assert.notEqual(end, -1, 'Could not locate loadExtensionExport wrapper marker'); + const wrapperSource = source.slice(start, end); + const wrapperCacheSource = fs.readFileSync(extensionWrapperCachePath, 'utf8'); + + assert.match(wrapperSource, /const lifecycleScope = createExtensionLifecycleScope\(timerRegistry\)/); + assert.match(wrapperCacheSource, /'window',\s*'self',\s*'document'/); + assert.match(wrapperSource, /bundleBuffer,\s*scopedWindow,\s*scopedWindow,\s*scopedWindow,\s*scopedWindow,\s*scopedDocument,/); + assert.doesNotMatch(wrapperSource, /const trackTimeout = \(cb: any, ms\?: any/); + }); + + await t.test('documents the pre-fix window API leak', () => { + host.reset(); + const registry = createTimerRegistry(); + const result = runWrapper({ registry, scoped: false }); + const beforeClear = host.snapshot(); + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.equal(registry.intervals.size, 0, 'legacy window.setInterval bypasses the bare timer registry'); + assert.equal(registry.eventListeners.size, 0, 'legacy window.addEventListener bypasses lifecycle cleanup'); + assert.equal(afterClear.total, beforeClear.total, 'legacy registry cleanup leaves window handles behind'); + + console.log('extension lifecycle leak before fix:', { beforeClear, afterClear }); + }); + + await t.test('cleans scoped window timers and listeners on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const result = runWrapper({ lifecycleScope, registry, scoped: true }); + const beforeClear = host.snapshot(); + const registryBeforeClear = { + intervals: registry.intervals.size, + eventListeners: registry.eventListeners.size, + }; + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.deepEqual(registryBeforeClear, { intervals: 1, eventListeners: 2 }); + assert.equal(afterClear.total, 0); + + console.log('extension lifecycle cleanup after fix:', { beforeClear, registryBeforeClear, afterClear }); + }); + + await t.test('prunes fired scoped one-shot timers and rafs', () => { + const oneShotCount = 1000; + + host.reset(); + const staleTimeoutRegistry = createTimerRegistry(); + const staleTimeoutApi = createBareTimerApi(staleTimeoutRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleTimeoutApi.setTimeout(() => {}, 0); + } + host.flushTimeouts(); + const staleTimeoutEntries = staleTimeoutRegistry.timeouts.size; + clearTimerRegistry(staleTimeoutRegistry); + + host.reset(); + const staleRafRegistry = createTimerRegistry(); + const staleRafApi = createBareTimerApi(staleRafRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleRafApi.requestAnimationFrame(() => {}); + } + host.flushRafs(); + const staleRafEntries = staleRafRegistry.rafs.size; + clearTimerRegistry(staleRafRegistry); + + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const intervalId = lifecycleScope.setInterval(() => {}, 60000); + let timeoutCalls = 0; + let timeoutThis = null; + let timeoutArgs = null; + let stringTimeoutScoped = false; + 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'); + } + lifecycleScope.setTimeout('globalThis.__stringTimeoutScoped = document.defaultView === window', 0); + + assert.equal(registry.intervals.size, 1); + assert.equal(registry.timeouts.size, oneShotCount + 1); + assert.equal(registry.timeoutClearers.size, oneShotCount + 1); + + host.flushTimeouts(); + stringTimeoutScoped = lifecycleScope.scopedWindow.__stringTimeoutScoped === true; + + assert.equal(timeoutCalls, oneShotCount); + assert.equal(timeoutThis, lifecycleScope.scopedWindow); + assert.deepEqual(timeoutArgs, [0, 'timeout-arg']); + assert.equal(stringTimeoutScoped, true); + 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, lifecycleScope.scopedWindow); + 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-wrapper-cache.mjs b/scripts/test-extension-wrapper-cache.mjs index e8a6bd7f..be718810 100644 --- a/scripts/test-extension-wrapper-cache.mjs +++ b/scripts/test-extension-wrapper-cache.mjs @@ -40,6 +40,9 @@ function createRunEnv() { Buffer, globalThis, globalThis, + globalThis, + globalThis, + globalThis.document, (fn, ...args) => trackTimeout(() => fn(...args), 0), trackClearTimeout, () => 0, diff --git a/scripts/test-menubar-native-update-cache.mjs b/scripts/test-menubar-native-update-cache.mjs new file mode 100644 index 00000000..924cf079 --- /dev/null +++ b/scripts/test-menubar-native-update-cache.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + createMenuBarNativeUpdateState, + getMenuBarNativeFileIconIdentityKey, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, +} = await importTs(path.join(root, 'src/main/menubar-native-update-cache.ts')); + +function makeItems(overrides = {}) { + return [ + { + type: 'label', + title: 'Timer', + }, + { + type: 'submenu', + id: '__mbi_group', + title: 'Controls', + children: [ + { + type: 'item', + id: '__mbi_pause', + title: 'Pause', + subtitle: 'Current session', + disabled: false, + alternate: { + id: '__mbi_pause_alt', + title: 'Pause without alert', + }, + ...overrides, + }, + ], + }, + { + type: 'separator', + }, + { + type: 'item', + id: '__mbi_stop', + title: 'Stop', + disabled: true, + }, + ]; +} + +function makePayload(overrides = {}) { + return { + extId: 'demo/timer', + iconPath: undefined, + iconDataUrl: undefined, + iconEmoji: '*', + iconTemplate: undefined, + iconBitmapScale: undefined, + fallbackIconDataUrl: '', + title: 'Timer', + tooltip: 'Timer status', + items: makeItems(), + ...overrides, + }; +} + +function makeCounters() { + return { + createTray: 0, + resolveTrayIcon: 0, + setImage: 0, + setTitle: 0, + setToolTip: 0, + buildMenuBarTemplate: 0, + buildFromTemplate: 0, + setContextMenu: 0, + statSync: 0, + }; +} + +function makeFs(counters, files) { + return { + statSync(pathValue) { + counters.statSync += 1; + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return { + size: file.size, + mtimeMs: file.mtimeMs, + isFile: () => true, + }; + }, + }; +} + +function runUncachedNativeWork(payloads) { + const counters = makeCounters(); + let trayExists = false; + + for (const payload of payloads) { + counters.resolveTrayIcon += 1; + if (!trayExists) { + counters.createTray += 1; + trayExists = true; + } else { + counters.setImage += 1; + } + + counters.setTitle += 1; + if (payload.tooltip) counters.setToolTip += 1; + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + } + + return counters; +} + +function runCachedNativeWork(payloads, { + files = new Map([['/tmp/supercmd-timer.png', { size: 64, mtimeMs: 10 }]]), + iconResolvesOk = true, + onBeforePayload, +} = {}) { + const counters = makeCounters(); + const state = createMenuBarNativeUpdateState(); + const fs = makeFs(counters, files); + let trayExists = false; + + for (let index = 0; index < payloads.length; index += 1) { + onBeforePayload?.(index, files); + const payload = payloads[index]; + const nextFileIdentityKey = getMenuBarNativeFileIconIdentityKey(payload, fs); + + if (!trayExists) { + counters.resolveTrayIcon += 1; + counters.createTray += 1; + trayExists = true; + rememberMenuBarNativeIcon(state, payload, iconResolvesOk, nextFileIdentityKey); + } else if (isMenuBarNativeIconRefreshNeeded(state, payload, nextFileIdentityKey)) { + counters.resolveTrayIcon += 1; + counters.setImage += 1; + rememberMenuBarNativeIcon(state, payload, iconResolvesOk, nextFileIdentityKey); + } + + const nextTitle = getMenuBarNativeTitle(payload, state.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(state, nextTitle)) { + counters.setTitle += 1; + rememberMenuBarNativeTitle(state, nextTitle); + } + + const nextTooltip = getMenuBarNativeTooltip(payload); + if (isMenuBarNativeTooltipUpdateNeeded(state, nextTooltip)) { + counters.setToolTip += 1; + rememberMenuBarNativeTooltip(state, nextTooltip); + } + + const nextItemsKey = getMenuBarNativeItemsKey(payload.items); + if (isMenuBarNativeMenuUpdateNeeded(state, nextItemsKey)) { + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + rememberMenuBarNativeMenu(state, nextItemsKey); + } + } + + return counters; +} + +test('MenuBarExtra native update cache', async (t) => { + await t.test('rebuilds stable native menus once across 60 title ticks', () => { + const payloads = Array.from({ length: 60 }, (_, index) => makePayload({ + title: `Timer ${String(index).padStart(2, '0')}`, + })); + const before = runUncachedNativeWork(payloads); + const after = runCachedNativeWork(payloads); + + t.diagnostic( + `60 title ticks with unchanged items: before=${before.buildFromTemplate} Menu.buildFromTemplate calls, ` + + `after=${after.buildFromTemplate}`, + ); + t.diagnostic( + `60 title ticks with unchanged items: before=${before.setContextMenu} setContextMenu calls, ` + + `after=${after.setContextMenu}`, + ); + + assert.equal(before.buildFromTemplate, 60); + assert.equal(before.setContextMenu, 60); + assert.equal(after.buildFromTemplate, 1); + assert.equal(after.setContextMenu, 1); + assert.equal(after.setTitle, 60, 'changing title text still updates the native title every tick'); + assert.equal(after.setToolTip, 1, 'stable tooltip is applied once'); + assert.equal(after.setImage, 0, 'stable non-file icon payload does not call setImage after tray creation'); + }); + + await t.test('rebuilds when serialized item payloads change', () => { + const payloads = [ + makePayload(), + makePayload({ title: 'Timer tick' }), + makePayload({ + title: 'Timer tick 2', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + makePayload({ + title: 'Timer tick 3', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + ]; + const after = runCachedNativeWork(payloads); + + assert.equal(after.buildFromTemplate, 2, 'initial menu and changed serialized items rebuild'); + assert.equal(after.setContextMenu, 2); + }); + + await t.test('skips native image refresh for stable file-backed tray icon title ticks', () => { + const payloads = Array.from({ length: 3 }, (_, index) => makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + title: `Timer ${index}`, + })); + const after = runCachedNativeWork(payloads); + + assert.equal(after.resolveTrayIcon, 1, 'stable file-backed icons resolve only for tray creation'); + assert.equal(after.setImage, 0, 'stable file-backed title ticks skip native setImage'); + assert.equal(after.statSync, 3, 'file identity is checked each accepted update'); + assert.equal(after.setTitle, 3, 'changing title text still updates the native title every tick'); + assert.equal(after.buildFromTemplate, 1); + }); + + await t.test('refreshes file-backed tray icons when file identity changes', () => { + const payloads = Array.from({ length: 3 }, (_, index) => makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + title: `Timer ${index}`, + })); + const after = runCachedNativeWork(payloads, { + onBeforePayload(index, files) { + if (index === 1) { + files.set('/tmp/supercmd-timer.png', { size: 65, mtimeMs: 11 }); + } + }, + }); + + assert.equal(after.resolveTrayIcon, 2, 'changed file identity re-resolves the tray icon'); + assert.equal(after.setImage, 1, 'changed file identity updates the existing native tray image'); + assert.equal(after.statSync, 3, 'file identity continues to be checked each accepted update'); + assert.equal(after.buildFromTemplate, 1); + }); + + await t.test('refreshes file-backed tray icons when image inputs change', () => { + const payloads = [ + makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + iconTemplate: true, + iconBitmapScale: 1, + fallbackIconDataUrl: 'data:image/png;base64,ZmFsbGJhY2sx', + }), + makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + iconTemplate: false, + iconBitmapScale: 1, + fallbackIconDataUrl: 'data:image/png;base64,ZmFsbGJhY2sx', + }), + makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + iconTemplate: false, + iconBitmapScale: 2, + fallbackIconDataUrl: 'data:image/png;base64,ZmFsbGJhY2sx', + }), + makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + iconTemplate: false, + iconBitmapScale: 2, + fallbackIconDataUrl: 'data:image/png;base64,ZmFsbGJhY2sy', + }), + ]; + const after = runCachedNativeWork(payloads); + + assert.equal(after.resolveTrayIcon, 4, 'template, scale, and fallback changes re-resolve the tray icon'); + assert.equal(after.setImage, 3, 'template, scale, and fallback changes update the existing tray image'); + assert.equal(after.statSync, 4); + assert.equal(after.buildFromTemplate, 1); + }); + + await t.test('preserves fallback title decisions', () => { + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '+' }), false), '+'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), false), '\u23f1'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), true), ''); + }); + + await t.test('keeps empty tooltip payloads non-clearing', () => { + const state = createMenuBarNativeUpdateState(); + const initialTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: 'Timer status' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, initialTooltip), true); + rememberMenuBarNativeTooltip(state, initialTooltip); + + const emptyTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: '' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, emptyTooltip), false); + assert.equal(state.tooltip, 'Timer status'); + }); +}); diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..c437cd48 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; + +async function withScriptCommandRunner(t, files, { + instrumentFs = false, + mockChildProcess = false, +} = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + mockChildProcess, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function createTimerHarness() { + let nextId = 1; + const active = new Map(); + const cleared = []; + + return { + setTimeout(callback, ms) { + const id = nextId++; + active.set(id, { callback, ms }); + return id; + }, + clearTimeout(id) { + cleared.push(id); + active.delete(id); + }, + get activeCount() { + return active.size; + }, + get cleared() { + return cleared; + }, + }; +} + +function createFakeProc() { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.killCount = 0; + proc.kill = () => { + proc.killCount += 1; + }; + return proc; +} + +async function withFakeGlobalTimers(timer, callback) { + const previousSetTimeout = globalThis.setTimeout; + const previousClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = timer.setTimeout; + globalThis.clearTimeout = timer.clearTimeout; + try { + return await callback(); + } finally { + globalThis.setTimeout = previousSetTimeout; + globalThis.clearTimeout = previousClearTimeout; + } +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + for (const streamName of ['stdout', 'stderr']) { + await t.test(`clears timeout handle when ${streamName} exceeds output limit`, async (t) => { + const timer = createTimerHarness(); + await withFakeGlobalTimers(timer, async () => { + const { module: runner, childProcess } = await withScriptCommandRunner(t, { + 'overflow.sh': `#!/bin/bash +${scriptHeader({ title: 'Overflow Command' })} +echo "overflow" +`, + }, { mockChildProcess: true }); + const spawned = []; + childProcess.spawn = (command, args, options) => { + const proc = createFakeProc(); + spawned.push({ command, args, options, proc }); + return proc; + }; + + const [command] = runner.discoverScriptCommands(); + const promise = runner.executeScriptCommand(command.id, undefined, 60_000); + const activeBeforeOverflow = timer.activeCount; + assert.equal(activeBeforeOverflow, 1); + assert.equal(spawned.length, 1); + + const { proc } = spawned[0]; + proc[streamName].emit('data', Buffer.alloc(MAX_OUTPUT_BYTES + 1, 'x')); + + const result = await promise; + assert.equal(result.exitCode, 1); + assert.match( + result.stderr, + streamName === 'stdout' + ? /Output exceeded 2MB limit\./ + : /Error output exceeded 2MB limit\./, + ); + assert.equal(proc.killCount, 1); + assert.equal(timer.activeCount, 0); + assert.equal(timer.cleared.length, 1); + t.diagnostic( + `${streamName} overflow timeout handles: before=${activeBeforeOverflow} after=${timer.activeCount} cleared=${timer.cleared.length}`, + ); + }); + }); + } + + await t.test('discovers metadata in large scripts', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + }); +}); diff --git a/scripts/test-whisper-file-buffer-read.mjs b/scripts/test-whisper-file-buffer-read.mjs new file mode 100644 index 00000000..f0abf780 --- /dev/null +++ b/scripts/test-whisper-file-buffer-read.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const { + resolveWhisperFileTranscriptionPlan, + shouldReadWhisperFileAudioBuffer, + transcribeWhisperAudioFile, +} = await importTs(path.resolve('src/main/whisper-transcribe-file.ts')); + +const AUDIO_PATH = '/tmp/supercmd-whisper-file-buffer-read/input.wav'; +const DEFAULT_FILE_BYTES = 73_400_320; + +function makeSettings(speechToTextModel) { + return { + ai: { + enabled: true, + whisperEnabled: true, + speechToTextModel, + speechLanguage: 'en-US', + openaiApiKey: 'openai-key', + elevenlabsApiKey: 'elevenlabs-key', + mistralApiKey: 'mistral-key', + }, + }; +} + +function makeHarness({ speechToTextModel = 'whispercpp', exists = true, fileBytes = DEFAULT_FILE_BYTES } = {}) { + const events = []; + const fakeAudioBuffer = Buffer.from('fake wav bytes'); + const calls = { + parakeet: [], + qwen3: [], + openai: [], + elevenlabs: [], + mistral: [], + whispercpp: [], + }; + let readCalls = 0; + let nodeBufferBytes = 0; + + const fs = { + existsSync(filePath) { + events.push(`exists:${filePath}`); + return exists; + }, + readFileSync(filePath) { + events.push(`read:${filePath}`); + readCalls += 1; + nodeBufferBytes += fileBytes; + return fakeAudioBuffer; + }, + unlinkSync(filePath) { + events.push(`unlink:${filePath}`); + }, + rmdirSync(dirPath, options) { + events.push(`rmdir:${dirPath}:${JSON.stringify(options)}`); + }, + }; + + const makeTranscriber = (label) => async (opts) => { + events.push(`transcribe:${label}`); + calls[label].push(opts); + return `${label} transcript`; + }; + + const deps = { + fs, + loadSettings: () => makeSettings(speechToTextModel), + isAIDisabledInSettings: (settings) => settings.ai?.enabled === false, + normalizeWhisperLanguageCode: (rawLanguage) => String(rawLanguage || 'en-US').split('-')[0].toLowerCase(), + resolveElevenLabsSttModel: (model) => model.replace(/^elevenlabs-/, '').replace(/-/g, '_') || 'scribe_v1', + getElevenLabsApiKey: (settings) => settings.ai?.elevenlabsApiKey || '', + getMistralApiKey: (settings) => settings.ai?.mistralApiKey || '', + getWhisperCppModelStatus: () => { + events.push('status:whispercpp'); + return { state: 'downloaded' }; + }, + ensureWhisperCppServer: async () => { + events.push('ensure:whispercpp'); + }, + sendWhisperCppRequest: async (request) => { + events.push('send:whispercpp'); + calls.whispercpp.push(request); + return { text: 'whispercpp transcript' }; + }, + transcribeAudioWithParakeet: makeTranscriber('parakeet'), + transcribeAudioWithQwen3: makeTranscriber('qwen3'), + transcribeAudioWithElevenLabs: makeTranscriber('elevenlabs'), + transcribeAudioWithMistralVoxtral: makeTranscriber('mistral'), + transcribeAudio: makeTranscriber('openai'), + whisperCppModelName: 'base', + }; + + return { + calls, + deps, + events, + fakeAudioBuffer, + fileBytes, + get nodeBufferBytes() { + return nodeBufferBytes; + }, + get readCalls() { + return readCalls; + }, + }; +} + +function assertCleanupAfter(events, marker) { + const markerIndex = events.indexOf(marker); + const unlinkIndex = events.findIndex((event) => event.startsWith('unlink:')); + const rmdirIndex = events.findIndex((event) => event.startsWith('rmdir:')); + + assert.notEqual(markerIndex, -1, `${marker} should be recorded`); + assert.ok(unlinkIndex > markerIndex, 'temp audio file should be unlinked after transcription succeeds'); + assert.ok(rmdirIndex > unlinkIndex, 'temp audio directory should be removed after the file unlink'); +} + +test('provider plan marks only buffer-backed file providers as requiring a Node buffer', () => { + const cases = [ + ['', 'whispercpp', false], + ['default', 'whispercpp', false], + ['whispercpp', 'whispercpp', false], + ['custom-local-model', 'whispercpp', false], + ['native', 'native', false], + ['parakeet', 'parakeet', true], + ['qwen3', 'qwen3', true], + ['openai-whisper-1', 'openai', true], + ['elevenlabs-scribe-v2', 'elevenlabs', true], + ['mistral-voxtral-mini-latest', 'mistral', true], + ]; + + for (const [speechToTextModel, expectedProvider, expectedRead] of cases) { + const plan = resolveWhisperFileTranscriptionPlan(speechToTextModel, { + whisperCppModelName: 'base', + resolveElevenLabsSttModel: (model) => model, + }); + assert.equal(plan.provider, expectedProvider, `${speechToTextModel || '(empty)'} provider`); + assert.equal( + shouldReadWhisperFileAudioBuffer(plan.provider), + expectedRead, + `${speechToTextModel || '(empty)'} read requirement` + ); + } +}); + +test('whispercpp file transcription sends the file path without reading a Node buffer', async () => { + const harness = makeHarness({ speechToTextModel: 'whispercpp' }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, 'whispercpp transcript'); + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.deepEqual(harness.calls.whispercpp, [ + { command: 'transcribe', file: AUDIO_PATH, language: 'en' }, + ]); + assertCleanupAfter(harness.events, 'send:whispercpp'); +}); + +test('buffer-backed file providers read the audio file once and clean up after transcription', async () => { + const cases = [ + ['parakeet', 'parakeet'], + ['qwen3', 'qwen3'], + ['openai-whisper-1', 'openai'], + ['elevenlabs-scribe-v2', 'elevenlabs'], + ['mistral-voxtral-mini-latest', 'mistral'], + ]; + + for (const [speechToTextModel, label] of cases) { + const harness = makeHarness({ speechToTextModel }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, `${label} transcript`); + assert.equal(harness.readCalls, 1, `${label} should read the captured file exactly once`); + assert.equal(harness.nodeBufferBytes, harness.fileBytes, `${label} should account for one full file buffer read`); + assert.equal(harness.calls[label].length, 1, `${label} transcriber should be called once`); + assert.equal(harness.calls[label][0].audioBuffer, harness.fakeAudioBuffer); + assert.equal(harness.calls[label][0].language, 'en'); + assert.equal(harness.calls[label][0].mimeType, 'audio/wav'); + assert.equal(harness.calls.whispercpp.length, 0, `${label} should not use the whisper.cpp request path`); + assertCleanupAfter(harness.events, `transcribe:${label}`); + } +}); + +test('missing audio file does not read a buffer or run cleanup', async () => { + const harness = makeHarness({ speechToTextModel: 'openai-whisper-1', exists: false }); + + await assert.rejects( + transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: harness.deps, + }), + /Audio file not found/ + ); + + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.equal(harness.events.some((event) => event.startsWith('unlink:')), false); + assert.equal(harness.events.some((event) => event.startsWith('rmdir:')), false); +}); + +test('instrumentation shows whispercpp avoids the legacy eager full-file read', async () => { + const legacy = makeHarness({ speechToTextModel: 'whispercpp' }); + legacy.deps.fs.readFileSync(AUDIO_PATH); + + const whisperCpp = makeHarness({ speechToTextModel: 'whispercpp' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: whisperCpp.deps, + }); + + const openai = makeHarness({ speechToTextModel: 'openai-whisper-1' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: openai.deps, + }); + + console.log( + `[whisper-file-buffer-read] before provider=whispercpp readCalls=${legacy.readCalls} ` + + `nodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=whispercpp readCalls=${whisperCpp.readCalls} ` + + `nodeBufferBytes=${whisperCpp.nodeBufferBytes} avoidedNodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=openai readCalls=${openai.readCalls} ` + + `nodeBufferBytes=${openai.nodeBufferBytes}` + ); + + assert.equal(legacy.readCalls, 1); + assert.equal(legacy.nodeBufferBytes, DEFAULT_FILE_BYTES); + assert.equal(whisperCpp.readCalls, 0); + assert.equal(whisperCpp.nodeBufferBytes, 0); + assert.equal(openai.readCalls, 1); + assert.equal(openai.nodeBufferBytes, DEFAULT_FILE_BYTES); +}); diff --git a/src/main/ai-provider.ts b/src/main/ai-provider.ts index 65942908..d8e88715 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -398,18 +398,7 @@ async function* streamGeminiChat( useHttps: true, }); - yield* parseSSE(response, (data) => { - try { - const parsed = JSON.parse(data); - const parts = parsed?.candidates?.[0]?.content?.parts; - if (Array.isArray(parts)) { - return parts.map((p: any) => (typeof p?.text === 'string' ? p.text : '')).join('') || null; - } - return null; - } catch { - return null; - } - }); + yield* streamGeminiSSE(response); } async function* streamOllamaChat( @@ -608,7 +597,7 @@ async function* streamGemini( const response = await httpRequest({ hostname: 'generativelanguage.googleapis.com', - path: `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, + path: `/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -616,21 +605,54 @@ async function* streamGemini( useHttps: true, }); - const parsed = JSON.parse(await readResponseBody(response) || '{}'); - const text = Array.isArray(parsed?.candidates?.[0]?.content?.parts) - ? parsed.candidates[0].content.parts - .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) - .join('') - : ''; + yield* streamGeminiSSE(response, { throwOnNoText: true }); +} - if (text) { - yield text; - return; - } +function extractGeminiResponseText(parsed: any): string { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; + + return parts + .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); +} - const reason = String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); - if (reason) throw new Error(`Gemini returned no text (${reason}).`); - throw new Error('Gemini returned no text.'); +function extractGeminiNoTextReason(parsed: any): string { + return String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); +} + +function createGeminiNoTextError(reason: string): Error { + if (reason) return new Error(`Gemini returned no text (${reason}).`); + return new Error('Gemini returned no text.'); +} + +async function* streamGeminiSSE( + response: http.IncomingMessage, + options: { throwOnNoText?: boolean } = {} +): AsyncGenerator { + let yieldedText = false; + let noTextReason = ''; + + yield* parseSSE(response, (data) => { + try { + const parsed = JSON.parse(data); + const text = extractGeminiResponseText(parsed); + if (text) { + yieldedText = true; + return text; + } + + const reason = extractGeminiNoTextReason(parsed); + if (reason && !noTextReason) noTextReason = reason; + return null; + } catch { + return null; + } + }); + + if (options.throwOnNoText && !yieldedText) { + throw createGeminiNoTextError(noTextReason); + } } // ─── Ollama ────────────────────────────────────────────────────────── @@ -727,14 +749,6 @@ function httpRequest(opts: HttpRequestOptions): Promise { }); } -async function readResponseBody(response: http.IncomingMessage): Promise { - let body = ''; - for await (const rawChunk of response) { - body += rawChunk.toString(); - } - return body; -} - async function* parseSSE( response: http.IncomingMessage, extractChunk: (data: string) => string | null @@ -822,10 +836,17 @@ function resolveUploadMeta(mimeType?: string): { filename: string; contentType: return { filename: 'audio.webm', contentType: 'audio/webm' }; } -export function transcribeAudio(opts: TranscribeOptions): Promise { - const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; - const uploadMeta = resolveUploadMeta(opts.mimeType); +interface MultipartUpload { + parts: Buffer[]; + contentLength: number; +} + +function getMultipartContentLength(parts: readonly Buffer[]): number { + return parts.reduce((total, part) => total + part.length, 0); +} +function buildTranscriptionMultipartUpload(opts: TranscribeOptions, boundary: string): MultipartUpload { + const uploadMeta = resolveUploadMeta(opts.mimeType); const parts: Buffer[] = []; // file field @@ -854,7 +875,22 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); + return { + parts, + contentLength: getMultipartContentLength(parts), + }; +} + +function writeBufferedRequestParts(req: http.ClientRequest, parts: readonly Buffer[]): void { + for (const part of parts) { + req.write(part); + } + req.end(); +} + +export function transcribeAudio(opts: TranscribeOptions): Promise { + const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; + const upload = buildTranscriptionMultipartUpload(opts, boundary); return new Promise((resolve, reject) => { const req = https.request( @@ -865,7 +901,7 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': upload.contentLength, }, }, (res) => { @@ -895,7 +931,6 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { }, { once: true }); } - req.write(body); - req.end(); + writeBufferedRequestParts(req, upload.parts); }); } diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..df060346 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -44,6 +44,7 @@ import { import type { AppSettings, BrowserProfileSetting, BrowserProfileFilters, BrowserProfileFilterKind, RelocateMode } from './settings-store'; import { recordRootSearchLaunchInState, type RootSearchRankingState } from '../shared/root-search-ranking-state'; import { streamAI, streamAIChat, isAIAvailable, transcribeAudio } from './ai-provider'; +import { transcribeWhisperAudioFile } from './whisper-transcribe-file'; import { scanAppRemnants } from './app-uninstaller'; import * as soulverCalculator from './soulver-calculator'; import { addMemory, buildMemoryContextSystemPrompt } from './memory'; @@ -218,6 +219,22 @@ import { importRaycastConfigFromFile, previewRaycastConfigImport, } from './raycast-config-import'; +import { + createMenuBarNativeUpdateState, + getMenuBarNativeFileIconIdentityKey, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, + type MenuBarNativeUpdateState, +} from './menubar-native-update-cache'; import { runExecCommand, type ExecCommandOptions } from './exec-command'; import { initialize as initAptabase, trackEvent } from "@aptabase/electron/main"; @@ -7370,8 +7387,18 @@ app.on('open-url', (event: any, url: string) => { // ─── Menu Bar (Tray) Management ───────────────────────────────────── const menuBarTrays = new Map>(); +const menuBarNativeUpdateStates = new Map(); let appTray: InstanceType | null = null; +function getMenuBarNativeUpdateState(extId: string): MenuBarNativeUpdateState { + let state = menuBarNativeUpdateStates.get(extId); + if (!state) { + state = createMenuBarNativeUpdateState(); + menuBarNativeUpdateStates.set(extId, state); + } + return state; +} + function buildDefaultMacTrayTemplateIcon(): any | null { if (process.platform !== 'darwin') return null; try { @@ -17811,102 +17838,28 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle( 'whisper-transcribe-file', async (_event: any, audioPath: string, options?: { language?: string }) => { - const s = loadSettings(); - if (isAIDisabledInSettings(s)) { - throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); - } - if (s.ai?.whisperEnabled === false) { - throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); - } - - if (!fs.existsSync(audioPath)) { - throw new Error(`Audio file not found: ${audioPath}`); - } - - const audioBuffer = fs.readFileSync(audioPath); - - // Reuse the existing whisper-transcribe logic by reading the file into a buffer - const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; - const language = normalizeWhisperLanguageCode(rawLang); - - let provider: 'parakeet' | 'qwen3' | 'whispercpp' | 'openai' | 'elevenlabs' | 'mistral' = 'whispercpp'; - let model = `ggml-${WHISPERCPP_MODEL_NAME}`; - const sttModel = s.ai.speechToTextModel || ''; - if (sttModel === 'parakeet') { - provider = 'parakeet'; - model = 'parakeet-tdt-0.6b-v3'; - } else if (sttModel === 'qwen3') { - provider = 'qwen3'; - model = 'qwen3-asr-0.6b'; - } else if (!sttModel || sttModel === 'default' || sttModel === 'whispercpp') { - provider = 'whispercpp'; - model = `ggml-${WHISPERCPP_MODEL_NAME}`; - } else if (sttModel === 'native') { - return ''; - } else if (sttModel.startsWith('openai-')) { - provider = 'openai'; - model = sttModel.slice('openai-'.length); - } else if (sttModel.startsWith('elevenlabs-')) { - provider = 'elevenlabs'; - model = resolveElevenLabsSttModel(sttModel); - } else if (sttModel.startsWith('mistral-')) { - provider = 'mistral'; - model = sttModel.slice('mistral-'.length) || 'voxtral-mini-latest'; - } else if (sttModel) { - model = sttModel; - } - - if (provider === 'openai' && !s.ai.openaiApiKey) { - throw new Error('OpenAI API key not configured.'); - } - const elevenLabsApiKey = getElevenLabsApiKey(s); - if (provider === 'elevenlabs' && !elevenLabsApiKey) { - throw new Error('ElevenLabs API key not configured.'); - } - const mistralApiKey = getMistralApiKey(s); - if (provider === 'mistral' && !mistralApiKey) { - throw new Error('Mistral API key not configured.'); - } - - // For whisper.cpp, use the file-path directly via the persistent server - // to avoid reading the file into a Node buffer just to write it again. - if (provider === 'whispercpp') { - const status = getWhisperCppModelStatus(); - if (status.state === 'downloading') { - throw new Error('Whisper model still downloading.'); - } - if (status.state !== 'downloaded') { - throw new Error('Whisper model not downloaded.'); - } - await ensureWhisperCppServer(); - const result = await sendWhisperCppRequest({ - command: 'transcribe', - file: audioPath, - language, - }); - // Clean up the temp file after transcription - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - return result.text || ''; - } - - // For cloud providers, use buffer-based transcription - const mimeType = 'audio/wav'; - const text = provider === 'parakeet' - ? await transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) - : provider === 'qwen3' - ? await transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) - : provider === 'elevenlabs' - ? await transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) - : provider === 'mistral' - ? await transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) - : await transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); - - // Clean up the temp file - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - - return text; + return await transcribeWhisperAudioFile({ + audioPath, + options, + deps: { + fs, + loadSettings, + isAIDisabledInSettings, + normalizeWhisperLanguageCode, + resolveElevenLabsSttModel, + getElevenLabsApiKey, + getMistralApiKey, + getWhisperCppModelStatus, + ensureWhisperCppServer, + sendWhisperCppRequest, + transcribeAudioWithParakeet, + transcribeAudioWithQwen3, + transcribeAudioWithElevenLabs, + transcribeAudioWithMistralVoxtral, + transcribeAudio, + whisperCppModelName: WHISPERCPP_MODEL_NAME, + }, + }); } ); ipcMain.handle( @@ -19005,9 +18958,11 @@ if let tiff = image?.tiffRepresentation { // Update / create a menu-bar Tray when the renderer sends menu structure ipcMain.on('menubar-update', (_event: any, data: any) => { - const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, title, tooltip, items } = data; + const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, items } = data; let tray = menuBarTrays.get(extId); + const updateState = getMenuBarNativeUpdateState(extId); + const nextIconFileIdentityKey = getMenuBarNativeFileIconIdentityKey(data, fs); const createNativeImageFromMenuIcon = ( payload: { pathValue?: string; dataUrlValue?: string; bitmapScale?: number }, @@ -19103,33 +19058,39 @@ if let tiff = image?.tiffRepresentation { const icon = resolveTrayIcon(); tray = new Tray(icon); menuBarTrays.set(extId, tray); + rememberMenuBarNativeIcon(updateState, data, lastResolvedTrayIconOk, nextIconFileIdentityKey); + } else if (isMenuBarNativeIconRefreshNeeded(updateState, data, nextIconFileIdentityKey)) { + // Refresh icon on accepted updates after creation (first payload can be incomplete). + tray.setImage(resolveTrayIcon()); + rememberMenuBarNativeIcon(updateState, data, lastResolvedTrayIconOk, nextIconFileIdentityKey); } - // Always refresh icon on update (first payload can be incomplete). - tray.setImage(resolveTrayIcon()); - // Update title: if there's a text title, show it; if only emoji icon, show that - if (title) { - tray.setTitle(title); - } else if (iconEmoji) { - tray.setTitle(iconEmoji); - } else if (!lastResolvedTrayIconOk) { - // Keep tray visible even when extension provides neither icon nor title. - tray.setTitle('⏱'); - } else { - tray.setTitle(''); + const nextTitle = getMenuBarNativeTitle(data, updateState.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(updateState, nextTitle)) { + tray.setTitle(nextTitle); + rememberMenuBarNativeTitle(updateState, nextTitle); + } + const nextTooltip = getMenuBarNativeTooltip(data); + if (isMenuBarNativeTooltipUpdateNeeded(updateState, nextTooltip)) { + tray.setToolTip(nextTooltip); + rememberMenuBarNativeTooltip(updateState, nextTooltip); } - if (tooltip) tray.setToolTip(tooltip); // Build native menu from serialized items - const menuTemplate = buildMenuBarTemplate(items, extId); - const menu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(menu); + const nextItemsKey = getMenuBarNativeItemsKey(items); + if (isMenuBarNativeMenuUpdateNeeded(updateState, nextItemsKey)) { + const menuTemplate = buildMenuBarTemplate(items, extId); + const menu = Menu.buildFromTemplate(menuTemplate); + tray.setContextMenu(menu); + rememberMenuBarNativeMenu(updateState, nextItemsKey); + } }); ipcMain.on('menubar-remove', (_event: any, data: any) => { const extId = String(data?.extId || '').trim(); if (!extId) return; + menuBarNativeUpdateStates.delete(extId); const tray = menuBarTrays.get(extId); if (!tray) return; try { @@ -19384,4 +19345,5 @@ app.on('will-quit', () => { tray.destroy(); } menuBarTrays.clear(); + menuBarNativeUpdateStates.clear(); }); diff --git a/src/main/menubar-native-update-cache.ts b/src/main/menubar-native-update-cache.ts new file mode 100644 index 00000000..90cd2c34 --- /dev/null +++ b/src/main/menubar-native-update-cache.ts @@ -0,0 +1,179 @@ +/** + * Main-process MenuBarExtra native update cache. + * + * Tracks the last native tray sub-payload applied per extension so title-only + * ticks do not rebuild unchanged Electron menu templates. + */ + +export type MenuBarNativeUpdatePayload = { + iconPath?: unknown; + iconDataUrl?: unknown; + iconEmoji?: unknown; + iconTemplate?: unknown; + iconBitmapScale?: unknown; + fallbackIconDataUrl?: unknown; + title?: unknown; + tooltip?: unknown; + items?: unknown; +}; + +export type MenuBarNativeFileStatFs = { + statSync: (pathValue: string) => { + size?: number; + mtimeMs?: number; + isFile?: () => boolean; + }; +}; + +export type MenuBarNativeUpdateState = { + iconKey: string | null; + iconFileIdentityKey: string | null; + lastResolvedTrayIconOk: boolean; + title: string | null; + tooltip: string | null; + itemsKey: string | null; +}; + +export function createMenuBarNativeUpdateState(): MenuBarNativeUpdateState { + return { + iconKey: null, + iconFileIdentityKey: null, + lastResolvedTrayIconOk: false, + title: null, + tooltip: null, + itemsKey: null, + }; +} + +export function getMenuBarNativeIconKey(payload: MenuBarNativeUpdatePayload): string { + return safeJsonStringify([ + payload.iconPath ?? null, + payload.iconDataUrl ?? null, + payload.iconEmoji ?? null, + typeof payload.iconTemplate === 'boolean' ? payload.iconTemplate : null, + payload.iconBitmapScale ?? null, + payload.fallbackIconDataUrl ?? null, + ]); +} + +export function hasFileBackedMenuBarNativeIcon(payload: MenuBarNativeUpdatePayload): boolean { + return typeof payload.iconPath === 'string' && payload.iconPath.trim().length > 0; +} + +export function getMenuBarNativeFileIconIdentityKey( + payload: MenuBarNativeUpdatePayload, + fs: MenuBarNativeFileStatFs, +): string | null { + if (!hasFileBackedMenuBarNativeIcon(payload)) return null; + const pathValue = (payload.iconPath as string).trim(); + try { + const stat = fs.statSync(pathValue); + if (typeof stat?.isFile === 'function' && !stat.isFile()) { + return `path:${pathValue}|missing`; + } + const size = Number.isFinite(Number(stat?.size)) ? Number(stat.size) : 0; + const mtimeMs = Number.isFinite(Number(stat?.mtimeMs)) ? Number(stat.mtimeMs) : 0; + return `path:${pathValue}|mtimeMs=${mtimeMs}|bytes=${size}`; + } catch { + return `path:${pathValue}|missing`; + } +} + +export function isMenuBarNativeIconRefreshNeeded( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, + nextFileIdentityKey?: string | null, +): boolean { + const nextIconKey = getMenuBarNativeIconKey(payload); + if (state.iconKey !== nextIconKey) return true; + if (!hasFileBackedMenuBarNativeIcon(payload)) return false; + if (nextFileIdentityKey === undefined) return true; + return state.iconFileIdentityKey !== nextFileIdentityKey; +} + +export function rememberMenuBarNativeIcon( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, + fileIdentityKey: string | null = null, +): void { + state.iconKey = getMenuBarNativeIconKey(payload); + state.iconFileIdentityKey = fileIdentityKey; + state.lastResolvedTrayIconOk = lastResolvedTrayIconOk; +} + +export function getMenuBarNativeTitle( + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, +): string { + const title = normalizeMenuBarNativeString(payload.title); + if (title) return title; + const iconEmoji = normalizeMenuBarNativeString(payload.iconEmoji); + if (iconEmoji) return iconEmoji; + return lastResolvedTrayIconOk ? '' : '\u23f1'; +} + +export function isMenuBarNativeTitleUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTitle: string, +): boolean { + return state.title !== nextTitle; +} + +export function rememberMenuBarNativeTitle( + state: MenuBarNativeUpdateState, + nextTitle: string, +): void { + state.title = nextTitle; +} + +export function getMenuBarNativeTooltip(payload: MenuBarNativeUpdatePayload): string { + return normalizeMenuBarNativeString(payload.tooltip); +} + +export function isMenuBarNativeTooltipUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): boolean { + return nextTooltip.length > 0 && state.tooltip !== nextTooltip; +} + +export function rememberMenuBarNativeTooltip( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): void { + state.tooltip = nextTooltip; +} + +export function getMenuBarNativeItemsKey(items: unknown): string { + return safeJsonStringify(items ?? []); +} + +export function isMenuBarNativeMenuUpdateNeeded( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): boolean { + return state.itemsKey !== nextItemsKey; +} + +export function rememberMenuBarNativeMenu( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): void { + state.itemsKey = nextItemsKey; +} + +function normalizeMenuBarNativeString(value: unknown): string { + if (typeof value === 'string') return value; + if (value == null) return ''; + return String(value); +} + +function safeJsonStringify(value: unknown): string { + try { + const result = JSON.stringify(value); + return typeof result === 'string' ? result : ''; + } catch { + return String(value); + } +} diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..89bfb722 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -530,6 +530,7 @@ export async function executeScriptCommand( let settled = false; let stdoutBytes = 0; let stderrBytes = 0; + let timeout: ReturnType | null = null; const proc = spawn(spawnCommand, spawnArgs, { cwd, @@ -540,6 +541,10 @@ export async function executeScriptCommand( const finalize = (payload: { stdout: string; stderr: string; exitCode: number }) => { if (settled) return; settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } resolve(payload); }; @@ -575,7 +580,7 @@ export async function executeScriptCommand( } }); - const timeout = setTimeout(() => { + timeout = setTimeout(() => { if (settled) return; try { proc.kill(); } catch {} finalize({ @@ -586,7 +591,6 @@ export async function executeScriptCommand( }, timeoutMs); proc.on('close', (code: number | null) => { - clearTimeout(timeout); finalize({ stdout, stderr, @@ -595,7 +599,6 @@ export async function executeScriptCommand( }); proc.on('error', (error: Error) => { - clearTimeout(timeout); finalize({ stdout, stderr: `${stderr}\n${error.message}`, diff --git a/src/main/whisper-transcribe-file.ts b/src/main/whisper-transcribe-file.ts new file mode 100644 index 00000000..fabaf1cb --- /dev/null +++ b/src/main/whisper-transcribe-file.ts @@ -0,0 +1,197 @@ +import * as path from 'path'; +import type { AppSettings } from './settings-store'; + +export type WhisperFileTranscriptionProvider = + | 'parakeet' + | 'qwen3' + | 'whispercpp' + | 'openai' + | 'elevenlabs' + | 'mistral' + | 'native'; + +export interface WhisperFileTranscriptionPlan { + provider: WhisperFileTranscriptionProvider; + model: string; +} + +export interface WhisperFileSystem { + existsSync(filePath: string): boolean; + readFileSync(filePath: string): Buffer; + unlinkSync(filePath: string): void; + rmdirSync(filePath: string, options?: { recursive?: boolean }): void; +} + +type WhisperCppModelStatus = { + state: string; +}; + +type BufferTranscriber = (opts: { + audioBuffer: Buffer; + language?: string; + mimeType?: string; +}) => Promise; + +type CloudBufferTranscriber = (opts: { + audioBuffer: Buffer; + apiKey: string; + model: string; + language?: string; + mimeType?: string; +}) => Promise; + +export interface TranscribeWhisperAudioFileDeps { + fs: WhisperFileSystem; + loadSettings(): AppSettings; + isAIDisabledInSettings(settings: AppSettings): boolean; + normalizeWhisperLanguageCode(rawLanguage?: string): string; + resolveElevenLabsSttModel(model: string): string; + getElevenLabsApiKey(settings: AppSettings): string; + getMistralApiKey(settings: AppSettings): string; + getWhisperCppModelStatus(): WhisperCppModelStatus; + ensureWhisperCppServer(): Promise; + sendWhisperCppRequest(request: Record): Promise; + transcribeAudioWithParakeet: BufferTranscriber; + transcribeAudioWithQwen3: BufferTranscriber; + transcribeAudioWithElevenLabs: CloudBufferTranscriber; + transcribeAudioWithMistralVoxtral: CloudBufferTranscriber; + transcribeAudio: CloudBufferTranscriber; + whisperCppModelName: string; +} + +export function resolveWhisperFileTranscriptionPlan( + sttModel: string | undefined, + deps: { + whisperCppModelName: string; + resolveElevenLabsSttModel(model: string): string; + } +): WhisperFileTranscriptionPlan { + const rawModel = sttModel || ''; + let provider: WhisperFileTranscriptionProvider = 'whispercpp'; + let model = `ggml-${deps.whisperCppModelName}`; + + if (rawModel === 'parakeet') { + provider = 'parakeet'; + model = 'parakeet-tdt-0.6b-v3'; + } else if (rawModel === 'qwen3') { + provider = 'qwen3'; + model = 'qwen3-asr-0.6b'; + } else if (!rawModel || rawModel === 'default' || rawModel === 'whispercpp') { + provider = 'whispercpp'; + model = `ggml-${deps.whisperCppModelName}`; + } else if (rawModel === 'native') { + provider = 'native'; + model = ''; + } else if (rawModel.startsWith('openai-')) { + provider = 'openai'; + model = rawModel.slice('openai-'.length); + } else if (rawModel.startsWith('elevenlabs-')) { + provider = 'elevenlabs'; + model = deps.resolveElevenLabsSttModel(rawModel); + } else if (rawModel.startsWith('mistral-')) { + provider = 'mistral'; + model = rawModel.slice('mistral-'.length) || 'voxtral-mini-latest'; + } else if (rawModel) { + model = rawModel; + } + + return { provider, model }; +} + +export function shouldReadWhisperFileAudioBuffer(provider: WhisperFileTranscriptionProvider): boolean { + return provider === 'parakeet' + || provider === 'qwen3' + || provider === 'openai' + || provider === 'elevenlabs' + || provider === 'mistral'; +} + +function cleanupWhisperFileAudioPath(fs: WhisperFileSystem, audioPath: string): void { + try { fs.unlinkSync(audioPath); } catch {} + try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} +} + +export async function transcribeWhisperAudioFile(params: { + audioPath: string; + options?: { language?: string }; + deps: TranscribeWhisperAudioFileDeps; +}): Promise { + const { audioPath, options, deps } = params; + const s = deps.loadSettings(); + if (deps.isAIDisabledInSettings(s)) { + throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); + } + if (s.ai?.whisperEnabled === false) { + throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); + } + + if (!deps.fs.existsSync(audioPath)) { + throw new Error(`Audio file not found: ${audioPath}`); + } + + const { provider, model } = resolveWhisperFileTranscriptionPlan( + s.ai.speechToTextModel || '', + { + whisperCppModelName: deps.whisperCppModelName, + resolveElevenLabsSttModel: deps.resolveElevenLabsSttModel, + } + ); + if (provider === 'native') { + return ''; + } + + const audioBuffer = shouldReadWhisperFileAudioBuffer(provider) + ? deps.fs.readFileSync(audioPath) + : null; + + const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; + const language = deps.normalizeWhisperLanguageCode(rawLang); + + if (provider === 'openai' && !s.ai.openaiApiKey) { + throw new Error('OpenAI API key not configured.'); + } + const elevenLabsApiKey = deps.getElevenLabsApiKey(s); + if (provider === 'elevenlabs' && !elevenLabsApiKey) { + throw new Error('ElevenLabs API key not configured.'); + } + const mistralApiKey = deps.getMistralApiKey(s); + if (provider === 'mistral' && !mistralApiKey) { + throw new Error('Mistral API key not configured.'); + } + + if (provider === 'whispercpp') { + const status = deps.getWhisperCppModelStatus(); + if (status.state === 'downloading') { + throw new Error('Whisper model still downloading.'); + } + if (status.state !== 'downloaded') { + throw new Error('Whisper model not downloaded.'); + } + await deps.ensureWhisperCppServer(); + const result = await deps.sendWhisperCppRequest({ + command: 'transcribe', + file: audioPath, + language, + }); + cleanupWhisperFileAudioPath(deps.fs, audioPath); + return result.text || ''; + } + + if (!audioBuffer) { + throw new Error(`No audio buffer available for ${provider} transcription.`); + } + + const mimeType = 'audio/wav'; + const text = provider === 'parakeet' + ? await deps.transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) + : provider === 'qwen3' + ? await deps.transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) + : provider === 'elevenlabs' + ? await deps.transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) + : provider === 'mistral' + ? await deps.transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) + : await deps.transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); + + cleanupWhisperFileAudioPath(deps.fs, audioPath); + return text; +} diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index bf558af6..24e238c3 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -3713,29 +3713,374 @@ function ensureGlobals() { } } +type ExtensionTimerHandle = any; + +interface TrackedEventListener { + target: EventTarget; + type: string; + listener: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; + capture: boolean; +} + /** - * Per-ExtensionView registry of timer handles created by the extension's - * sandboxed setInterval/setTimeout/requestAnimationFrame. Cleared on unmount - * so a buggy extension (e.g. raycast/timers) cannot leak timers + retained - * fibers into the host renderer. + * Per-ExtensionView registry of lifecycle handles created by the extension's + * sandboxed timers and scoped event targets. Cleared on unmount so a buggy + * extension (e.g. raycast/timers) cannot leak DOM handles + retained fibers + * into the host renderer. */ export interface TimerRegistry { - intervals: Set; - timeouts: Set; - rafs: Set; + intervals: Set; + timeouts: Set; + rafs: Set; + intervalClearers: Map void>; + timeoutClearers: Map void>; + rafClearers: Map void>; + eventListeners: Set; } export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; + return { + intervals: new Set(), + timeouts: new Set(), + rafs: new Set(), + intervalClearers: new Map(), + timeoutClearers: new Map(), + rafClearers: new Map(), + eventListeners: new Set(), + }; } export function clearTimerRegistry(registry: TimerRegistry): void { - registry.intervals.forEach((id) => window.clearInterval(id)); - registry.timeouts.forEach((id) => window.clearTimeout(id)); - registry.rafs.forEach((id) => window.cancelAnimationFrame(id)); + Array.from(registry.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(); +} + +function getEventListenerCapture(options?: boolean | AddEventListenerOptions): boolean { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function findTrackedEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): TrackedEventListener | null { + if (!registry || !listener) return null; + const capture = getEventListenerCapture(options); + for (const entry of registry.eventListeners) { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + return entry; + } + } + return null; +} + +function trackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): void { + if (!registry || !listener) return; + if (findTrackedEventListener(registry, target, type, listener, options)) return; + registry.eventListeners.add({ + target, + type, + listener, + options, + capture: getEventListenerCapture(options), + }); +} + +function untrackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions +): void { + if (!registry || !listener) return; + const capture = getEventListenerCapture(options); + Array.from(registry.eventListeners).forEach((entry) => { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + registry.eventListeners.delete(entry); + } + }); +} + +function shouldBindHostFunction(prop: string | symbol, value: Function): boolean { + if (typeof prop === 'string' && /^[A-Z]/.test(prop)) return false; + try { + if (/^class\s/.test(Function.prototype.toString.call(value))) return false; + } catch {} + return true; +} + +function getWindowPeer(hostWindow: any, scopedWindow: any, prop: 'top' | 'parent' | 'opener'): any { + try { + const value = hostWindow?.[prop]; + return value === hostWindow ? scopedWindow : value; + } catch { + return scopedWindow; + } +} + +function createScopedHostProxy( + host: any, + registry: TimerRegistry | undefined, + kind: 'window' | 'document', + getScopedWindow: () => any, + getScopedDocument: () => any, + timerApi?: { + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + } +): any { + const boundFunctions = new WeakMap(); + const target = {}; + + return new Proxy(target, { + get(_target, prop) { + if (kind === 'window') { + if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'global') return getScopedWindow(); + if (prop === 'top' || prop === 'parent' || prop === 'opener') return getWindowPeer(host, getScopedWindow(), prop); + if (prop === 'document') return getScopedDocument(); + if (prop === 'setInterval') return timerApi?.setInterval; + if (prop === 'clearInterval') return timerApi?.clearInterval; + if (prop === 'setTimeout') return timerApi?.setTimeout; + if (prop === 'clearTimeout') return timerApi?.clearTimeout; + if (prop === 'requestAnimationFrame') return timerApi?.requestAnimationFrame; + if (prop === 'cancelAnimationFrame') return timerApi?.cancelAnimationFrame; + } else if (prop === 'defaultView') { + return getScopedWindow(); + } + + if (prop === 'addEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => { + host.addEventListener(type, listener as any, options); + trackEventListener(registry, host, type, listener, options); + }; + } + + if (prop === 'removeEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => { + host.removeEventListener(type, listener as any, options); + untrackEventListener(registry, host, type, listener, options); + }; + } + + const value = Reflect.get(host, prop, host); + if (typeof value !== 'function' || !shouldBindHostFunction(prop, value)) return value; + const cached = boundFunctions.get(value); + if (cached) return cached; + const bound = value.bind(host) as Function; + boundFunctions.set(value, bound); + return bound; + }, + set(_target, prop, value) { + return Reflect.set(host, prop, value, host); + }, + has(_target, prop) { + return prop in host; + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(host, prop); + }, + defineProperty(_target, prop, descriptor) { + return Reflect.defineProperty(host, prop, descriptor); + }, + getOwnPropertyDescriptor(_target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(host, prop); + if (!descriptor) return undefined; + return { ...descriptor, configurable: true }; + }, + ownKeys() { + return Reflect.ownKeys(host); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(host); + }, + }); +} + +export function createExtensionLifecycleScope( + registry: TimerRegistry | undefined, + hostWindow: any = window, + hostDocument: any = hostWindow?.document ?? document +): { + scopedWindow: any; + scopedDocument: any; + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + setImmediate: (callback: Function, ...args: any[]) => ExtensionTimerHandle; + clearImmediate: (id?: ExtensionTimerHandle) => void; +} { + const nativeSetInterval = hostWindow.setInterval.bind(hostWindow); + const nativeClearInterval = hostWindow.clearInterval.bind(hostWindow); + const nativeSetTimeout = hostWindow.setTimeout.bind(hostWindow); + const nativeClearTimeout = hostWindow.clearTimeout.bind(hostWindow); + const nativeRequestAnimationFrame = hostWindow.requestAnimationFrame.bind(hostWindow); + const nativeCancelAnimationFrame = hostWindow.cancelAnimationFrame.bind(hostWindow); + let scopedWindow: any; + let scopedDocument: any; + + 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[]) => { + const scopedThis = scopedWindow ?? thisArg; + if (typeof handler === 'function') { + return handler.apply(scopedThis, callbackArgs); + } + const source = String(handler); + return Function('window', 'self', 'globalThis', 'document', source).call( + scopedThis, + scopedWindow ?? hostWindow, + scopedWindow ?? hostWindow, + scopedWindow ?? hostWindow, + scopedDocument ?? hostDocument, + ); + }; + 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(scopedWindow ?? 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, + }; + + 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, + }; } /** @@ -4265,49 +4610,24 @@ 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); + const lifecycleScope = createExtensionLifecycleScope(timerRegistry); + const { + scopedWindow, + scopedDocument, + setImmediate: trackSetImmediate, + clearImmediate: trackClearImmediate, + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + } = lifecycleScope; // Execute the CJS bundle in a function scope. // We pass all the standard CJS arguments plus `process`, `Buffer`, - // and `global` to ensure they are always in scope even when the - // extension code references them without importing. + // `global`, and scoped browser globals to ensure they are always in scope + // even when the extension code references them without importing. // Browser-detection bypass — the OpenAI v4 SDK (and several other // "isomorphic" SDKs) refuse to start unless the caller passes // `dangerouslyAllowBrowser: true`. The check is @@ -4335,8 +4655,11 @@ function loadExtensionExport( '/extension', bundleProcess, bundleBuffer, - globalThis, - globalThis, + scopedWindow, + scopedWindow, + scopedWindow, + scopedWindow, + scopedDocument, trackSetImmediate, trackClearImmediate, trackInterval, diff --git a/src/renderer/src/utils/extension-wrapper-cache.ts b/src/renderer/src/utils/extension-wrapper-cache.ts index 7c4fe67e..9f0ea306 100644 --- a/src/renderer/src/utils/extension-wrapper-cache.ts +++ b/src/renderer/src/utils/extension-wrapper-cache.ts @@ -10,6 +10,9 @@ export const EXTENSION_WRAPPER_ARGUMENTS = [ 'Buffer', 'global', 'globalThis', + 'window', + 'self', + 'document', 'setImmediate', 'clearImmediate', 'setInterval',