From 1258b7e945646051a63bece81ed9b9997d54c845 Mon Sep 17 00:00:00 2001 From: hashfunction Date: Tue, 15 Sep 2026 12:25:31 -0700 Subject: [PATCH 1/3] feat: add GitHub Copilot CLI report provider Integrate tool-free Copilot report generation with isolated configuration, private stdin and image attachments, validated JSONL output, model settings, and CLI Bridge support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 18 +- package-lock.json | 2 +- package.json | 1 + src/main/ai/bridge/BridgeCliProvider.ts | 1 + src/main/ai/providers/CopilotCliConfig.ts | 62 +++++++ src/main/ai/providers/ProfiledCliProvider.ts | 82 ++++++++- src/main/ipc/analysisProviderHandlers.ts | 2 + .../settings/analysisProviderOptions.ts | 7 + .../settings/analysisProviderViewState.ts | 2 + src/renderer/traditionalChinese.ts | 3 + src/shared/cliBridgeProtocol.ts | 1 + src/shared/types.ts | 2 + tests/integration/copilotCliIsolation.test.ts | 108 ++++++++++++ .../unit/ai/analysisProviderRegistry.test.ts | 4 + tests/unit/ai/profiledCliProvider.test.ts | 166 +++++++++++++++++- tests/unit/analysisProviderIpc.test.ts | 6 + tests/unit/analysisProviderOptions.test.ts | 6 +- tests/unit/analysisProviderSettings.test.ts | 9 +- tests/unit/cliBridgeProtocol.test.ts | 5 + 19 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 src/main/ai/providers/CopilotCliConfig.ts create mode 100644 tests/integration/copilotCliIsolation.test.ts diff --git a/README.md b/README.md index c362c7c..62c43f5 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ In the Mac App Store app, CLI providers use the optional local companion describ |---|---|---| | **Codex CLI** | CLI | Your installed Codex CLI and existing ChatGPT login, in a read-only ephemeral session | | **Claude Code CLI** | CLI | The Claude Code CLI you're already signed in to | +| **GitHub Copilot CLI** | CLI | Copilot CLI 1.0.83+ with your existing GitHub login, in an isolated tool-free session | | **OpenCode** | CLI | Your configured OpenCode provider, with a per-run agent that denies every tool action | | **Cursor Agent CLI** | CLI | Cursor Agent in non-interactive, read-only Ask mode | | **Qwen Code** | CLI | Qwen Code in safe, non-interactive plan mode with mutation tools excluded | @@ -299,7 +300,22 @@ In the Mac App Store app, CLI providers use the optional local companion describ **Failure is safe by design.** If the provider you picked errors out, the deterministic Local rules report is written anyway, and the popover names the provider and the reason. Your recording, audio, and marks were already on disk before analysis started. An explicit CLI choice never silently becomes an Anthropic call. -Codex CLI and OpenCode can receive captured screenshots. Transcript-only CLI adapters reject screenshot-only sessions instead of inventing visual findings. +Codex CLI, GitHub Copilot CLI, and OpenCode can receive captured screenshots. Transcript-only CLI adapters reject screenshot-only sessions instead of inventing visual findings. + +To use **GitHub Copilot CLI**, install or update `copilot`, run `copilot login` +in Terminal, then select **GitHub Copilot CLI** in Report Settings and refresh +providers. Leave the model blank to use your Copilot default, or enter a Copilot +model ID. Discovery verifies the installed version; authentication is verified +when a report runs. The optional CLI Bridge provides the same integration for +the Mac App Store app. + +Copilot receives the transcript over stdin and screenshots as attachments. +Each report uses a temporary Copilot configuration that reuses only your login +identity (credentials remain in the OS credential store) and default model, not +your plugins, hooks, MCP servers, or saved permissions. All model tools and +custom instructions are disabled. Temporary context is removed after success +or failure. Environment-token authentication is also supported; plaintext +credential fallbacks are not copied into the temporary configuration. `processing-trace.json` records exactly what happened: diff --git a/package-lock.json b/package-lock.json index 93e797f..8b439d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "electron-log": "^5.4.4", "electron-store": "^11.0.2", "electron-updater": "^6.7.3", + "json5": "^2.2.3", "keytar": "^7.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -8786,7 +8787,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" diff --git a/package.json b/package.json index a09e586..81a9dec 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "electron-log": "^5.4.4", "electron-store": "^11.0.2", "electron-updater": "^6.7.3", + "json5": "^2.2.3", "keytar": "^7.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/main/ai/bridge/BridgeCliProvider.ts b/src/main/ai/bridge/BridgeCliProvider.ts index d757eb5..c30c896 100644 --- a/src/main/ai/bridge/BridgeCliProvider.ts +++ b/src/main/ai/bridge/BridgeCliProvider.ts @@ -8,6 +8,7 @@ import { CliBridgeClient, CliBridgeClientError } from './CliBridgeClient'; export const BRIDGE_CLI_PROVIDER_NAMES: Record = { 'codex-cli': 'Codex CLI', 'claude-cli': 'Claude Code CLI', + 'github-copilot-cli': 'GitHub Copilot CLI', 'opencode-cli': 'OpenCode', 'cursor-cli': 'Cursor Agent CLI', 'qwen-cli': 'Qwen Code', diff --git a/src/main/ai/providers/CopilotCliConfig.ts b/src/main/ai/providers/CopilotCliConfig.ts new file mode 100644 index 0000000..520e3da --- /dev/null +++ b/src/main/ai/providers/CopilotCliConfig.ts @@ -0,0 +1,62 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import JSON5 from 'json5'; +import { z } from 'zod'; + +const configSchema = z.record(z.string(), z.unknown()); +const accountSchema = z.object({ host: z.string(), login: z.string() }); +const identitySchema = z.object({ + loggedInUsers: z.array(accountSchema).optional(), + lastLoggedInUser: accountSchema.optional(), +}); + +async function readConfiguration(path: string): Promise> { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return {}; + throw error; + } + try { + return configSchema.parse(JSON5.parse(text)); + } catch { + // Parser messages may contain credentials from the source configuration. + throw new Error(`GitHub Copilot CLI could not read ${path}. Fix this configuration and try again.`); + } +} + +export async function prepareCopilotEnvironment( + temporaryDirectory: string, + environment: NodeJS.ProcessEnv, + homeDirectory: string, +): Promise { + const originalHome = environment.COPILOT_HOME || join(homeDirectory, '.copilot'); + const config = await readConfiguration(join(originalHome, 'config.json')); + const settings = await readConfiguration(join(originalHome, 'settings.json')); + const identity = identitySchema.safeParse(config); + if (!identity.success) { + throw new Error('GitHub Copilot CLI login configuration is invalid. Run copilot login and try again.'); + } + + const isolatedHome = join(temporaryDirectory, 'copilot-home'); + await mkdir(isolatedHome, { mode: 0o700 }); + // Only account identifiers are needed to reuse the OS credential store. + // Never copy plugins, hooks, MCP servers, permissions, or trusted folders. + await writeFile(join(isolatedHome, 'config.json'), JSON.stringify(identity.data), { + encoding: 'utf8', mode: 0o600, + }); + const model = settings.model ?? config.model; + await writeFile(join(isolatedHome, 'settings.json'), JSON.stringify({ + ...(typeof model === 'string' && model.trim() ? { model } : {}), + disableAllHooks: true, + memory: false, + ide: { autoConnect: false }, + }), { encoding: 'utf8', mode: 0o600 }); + + return { + COPILOT_HOME: isolatedHome, + COPILOT_CUSTOM_INSTRUCTIONS_DIRS: undefined, + COPILOT_DISABLE_TERMINAL_TITLE: 'true', + }; +} diff --git a/src/main/ai/providers/ProfiledCliProvider.ts b/src/main/ai/providers/ProfiledCliProvider.ts index e52235f..d9a266d 100644 --- a/src/main/ai/providers/ProfiledCliProvider.ts +++ b/src/main/ai/providers/ProfiledCliProvider.ts @@ -17,6 +17,7 @@ import { runCliProcess } from '../CliProcessRunner'; import type { CliProcessOptions, CliProcessResult } from '../CliProcessRunner'; import type { AIAnalysisResult } from '../types'; import type { AnalysisProviderAdapter } from './types'; +import { prepareCopilotEnvironment } from './CopilotCliConfig'; const PROBE_TIMEOUT_MS = 5_000; const ANALYSIS_TIMEOUT_MS = 180_000; @@ -29,6 +30,7 @@ export interface CliProviderProfile { executables: string[]; versionArgs?: string[]; minimumMajorVersion?: number; + minimumVersion?: string; promptViaStdin?: boolean; supportsImages?: boolean; requiredEnvironmentVariable?: string; @@ -51,6 +53,31 @@ function addImages(args: string[], imagePaths: string[], flag: string): string[] } export const CLI_PROVIDER_PROFILES: CliProviderProfile[] = [ + { + id: 'github-copilot-cli', + name: 'GitHub Copilot CLI', + executables: ['copilot'], + minimumVersion: '1.0.83', + promptViaStdin: true, + supportsImages: true, + buildArgs: ({ modelId, imagePaths }) => addModel(addImages([ + // Omit -p: Copilot reads piped stdin only when no prompt argument is supplied. + // An empty --available-tools list means "all" in Copilot, not "none". + '--excluded-tools=builtin:*,mcp:*,custom:*', + '--disable-builtin-mcps', + '--no-custom-instructions', + '--no-ask-user', + '--no-auto-update', + '--no-remote', + '--no-remote-export', + '--no-experimental', + '--no-bash-env', + '--silent', + '--output-format', 'json', + '--stream', 'off', + '--log-level', 'none', + ], imagePaths, '--attachment'), modelId), + }, { id: 'opencode-cli', name: 'OpenCode', @@ -158,6 +185,15 @@ const strictAnalysisSchema = z.object({ }).strict(), }).strict(); +const copilotCompletionSchema = z.object({ type: z.literal('result'), exitCode: z.literal(0) }); +const copilotMessageSchema = z.object({ + type: z.literal('assistant.message'), + data: z.object({ + content: z.string(), + toolRequests: z.array(z.unknown()).max(0).optional(), + }), +}); + function tryAnalysis(candidate: string): AIAnalysisResult | null { try { strictAnalysisSchema.parse(JSON.parse(candidate)); @@ -222,6 +258,17 @@ export function extractCliAnalysisResult(output: string): AIAnalysisResult { const values = parseJsonValues(trimmed); const records = values.flatMap((value) => Array.isArray(value) ? value : [value]); + if (copilotCompletionSchema.safeParse(records.at(-1)).success) { + const lastMessage = [...records].reverse().find((record) => ( + record !== null && typeof record === 'object' + && 'type' in record && record.type === 'assistant.message' + )); + const message = copilotMessageSchema.safeParse(lastMessage); + if (message.success) { + const parsed = tryAnalysis(message.data.data.content); + if (parsed) return parsed; + } + } const terminalCandidates = records .map(terminalCandidate) .filter((candidate): candidate is string => candidate !== null); @@ -302,6 +349,17 @@ function sanitizeCliDetail(value: string): string { .slice(0, 240); } +function meetsMinimumVersion(version: string, minimum: string): boolean { + const match = version.match(/(?:^|\s)(\d+)\.(\d+)\.(\d+)(?=[\s.]|$)/); + if (!match) return false; + const actual = match.slice(1).map(Number); + const required = minimum.split('.').map(Number); + for (let index = 0; index < required.length; index += 1) { + if (actual[index] !== required[index]) return actual[index] > required[index]; + } + return true; +} + function openCodeConfiguration(): object { return { $schema: 'https://opencode.ai/config.json', @@ -379,6 +437,20 @@ export class ProfiledCliProvider implements AnalysisProviderAdapter { }); } const version = versionResult.stdout.trim().split(/\r?\n/, 1)[0]; + if (this.profile.minimumVersion && !meetsMinimumVersion(version, this.profile.minimumVersion)) { + return this.cache({ + id: this.id, + name: this.name, + connection: 'cli', + installed: true, + executablePath, + ...(version ? { version } : {}), + authenticated: false, + ready: false, + models: [defaultModel], + diagnostic: `${this.name} ${this.profile.minimumVersion} or newer is required for tool-free report generation. Update ${this.profile.executables[0]}, then scan again.`, + }); + } if (this.profile.minimumMajorVersion) { const majorVersion = Number.parseInt(version.match(/\d+/)?.[0] || '', 10); if (!Number.isFinite(majorVersion) || majorVersion < this.profile.minimumMajorVersion) { @@ -434,7 +506,7 @@ export class ProfiledCliProvider implements AnalysisProviderAdapter { throw new Error('The session has no transcript or screenshots to analyze.'); } if (transcript === '[No transcript available]' && !this.profile.supportsImages) { - throw new Error(`${this.name} cannot analyze a screenshot-only session. Choose Codex CLI or OpenCode.`); + throw new Error(`${this.name} cannot analyze a screenshot-only session. Choose Codex CLI, GitHub Copilot CLI, or OpenCode.`); } const temporaryDirectory = await mkdtemp(join(tmpdir(), `markuprx-${this.id}-`)); @@ -479,6 +551,13 @@ export class ProfiledCliProvider implements AnalysisProviderAdapter { OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfiguration()), } : {}; + const copilotEnvironment = this.profile.id === 'github-copilot-cli' + ? await prepareCopilotEnvironment( + temporaryDirectory, + this.dependencies.env, + this.dependencies.homeDirectory, + ) + : {}; const result = await this.dependencies.run({ executable: status.executablePath, args: this.profile.buildArgs({ @@ -493,6 +572,7 @@ export class ProfiledCliProvider implements AnalysisProviderAdapter { ...buildCliEnvironment(status.executablePath, this.dependencies.env), ...this.profile.environment, ...openCodeEnvironment, + ...copilotEnvironment, CI: '1', NO_COLOR: '1', }, diff --git a/src/main/ipc/analysisProviderHandlers.ts b/src/main/ipc/analysisProviderHandlers.ts index 0d217a3..aba79ee 100644 --- a/src/main/ipc/analysisProviderHandlers.ts +++ b/src/main/ipc/analysisProviderHandlers.ts @@ -18,6 +18,7 @@ import type { IpcContext } from './types'; const PROVIDER_ORDER: AnalysisProvider[] = [ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -35,6 +36,7 @@ const MODEL_PROVIDERS = new Set([ 'anthropic-api', 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', diff --git a/src/renderer/components/settings/analysisProviderOptions.ts b/src/renderer/components/settings/analysisProviderOptions.ts index a2b4437..e4d7636 100644 --- a/src/renderer/components/settings/analysisProviderOptions.ts +++ b/src/renderer/components/settings/analysisProviderOptions.ts @@ -33,6 +33,12 @@ const ALL_PROVIDER_OPTIONS: AnalysisProviderOption[] = [ description: 'Use your installed and signed-in Claude Code CLI.', connectionBadge: 'CLI', }, + { + id: 'github-copilot-cli', + title: 'GitHub Copilot CLI', + description: 'Use your installed GitHub Copilot CLI and existing GitHub login.', + connectionBadge: 'CLI', + }, { id: 'opencode-cli', title: 'OpenCode', @@ -125,6 +131,7 @@ export function getModelDefaultLabel(provider: ModelAnalysisProvider): string { switch (provider) { case 'codex-cli': return 'Codex default'; case 'claude-cli': return 'Claude Code default'; + case 'github-copilot-cli': return 'GitHub Copilot default'; case 'opencode-cli': return 'OpenCode default'; case 'cursor-cli': return 'Cursor default'; case 'qwen-cli': return 'Qwen default'; diff --git a/src/renderer/components/settings/analysisProviderViewState.ts b/src/renderer/components/settings/analysisProviderViewState.ts index d09264a..23bea69 100644 --- a/src/renderer/components/settings/analysisProviderViewState.ts +++ b/src/renderer/components/settings/analysisProviderViewState.ts @@ -18,6 +18,7 @@ const PROVIDER_NAMES: Record = { 'anthropic-api': 'Anthropic API', 'codex-cli': 'Codex CLI', 'claude-cli': 'Claude Code CLI', + 'github-copilot-cli': 'GitHub Copilot CLI', 'opencode-cli': 'OpenCode', 'cursor-cli': 'Cursor Agent CLI', 'qwen-cli': 'Qwen Code', @@ -33,6 +34,7 @@ function checkingDetail(provider: Exclude): string { switch (provider) { case 'codex-cli': return 'Scanning for an installed and authenticated Codex CLI.'; case 'claude-cli': return 'Scanning for an installed and authenticated Claude Code CLI.'; + case 'github-copilot-cli': return 'Scanning for an installed GitHub Copilot CLI.'; case 'opencode-cli': return 'Scanning for an installed OpenCode CLI.'; case 'cursor-cli': return 'Scanning for an installed Cursor Agent CLI.'; case 'qwen-cli': return 'Scanning for an installed Qwen Code CLI.'; diff --git a/src/renderer/traditionalChinese.ts b/src/renderer/traditionalChinese.ts index 5275e84..c1cb3d3 100644 --- a/src/renderer/traditionalChinese.ts +++ b/src/renderer/traditionalChinese.ts @@ -136,6 +136,8 @@ const EXACT_TRANSLATIONS: Record = { 'Local Rules': '本機規則', 'Use your installed Codex CLI and existing ChatGPT login.': '使用已安裝的 Codex CLI 與現有 ChatGPT 登入狀態。', 'Use your installed and signed-in Claude Code CLI.': '使用已安裝且已登入的 Claude Code CLI。', + 'Use your installed GitHub Copilot CLI and existing GitHub login.': '使用已安裝的 GitHub Copilot CLI 與現有 GitHub 登入狀態。', + 'Scanning for an installed GitHub Copilot CLI.': '正在尋找已安裝的 GitHub Copilot CLI。', 'Generate reports with your configured OpenCode providers.': '使用已設定的 OpenCode 服務產生報告。', 'Generate reports with Cursor Agent in read-only Ask mode.': '使用 Cursor Agent 的唯讀 Ask 模式產生報告。', 'Use Qwen Code in safe, non-interactive mode.': '以安全的非互動模式使用 Qwen Code。', @@ -427,6 +429,7 @@ const EXACT_TRANSLATIONS: Record = { 'Runs through the companion CLI Bridge.': '會透過搭配使用的 CLI 橋接程式執行。', 'Codex default': 'Codex 預設值', 'Claude Code default': 'Claude Code 預設值', + 'GitHub Copilot default': 'GitHub Copilot 預設值', 'OpenCode default': 'OpenCode 預設值', 'Cursor default': 'Cursor 預設值', 'Qwen default': 'Qwen 預設值', diff --git a/src/shared/cliBridgeProtocol.ts b/src/shared/cliBridgeProtocol.ts index 9ee5dae..8aace3f 100644 --- a/src/shared/cliBridgeProtocol.ts +++ b/src/shared/cliBridgeProtocol.ts @@ -13,6 +13,7 @@ export const CLI_BRIDGE_MAX_SESSION_ITEMS = 2_000; export const CLI_BRIDGE_PROVIDER_IDS = [ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', diff --git a/src/shared/types.ts b/src/shared/types.ts index e4cfe37..0d1a04e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -148,6 +148,7 @@ export const ANALYSIS_PROVIDERS = [ 'anthropic-api', 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -187,6 +188,7 @@ const MODEL_ANALYSIS_PROVIDERS = new Set([ 'anthropic-api', 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', diff --git a/tests/integration/copilotCliIsolation.test.ts b/tests/integration/copilotCliIsolation.test.ts new file mode 100644 index 0000000..0a58257 --- /dev/null +++ b/tests/integration/copilotCliIsolation.test.ts @@ -0,0 +1,108 @@ +import { once } from 'node:events'; +import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import type { Session } from '../../src/main/SessionController'; +import { + CLI_PROVIDER_PROFILES, + ProfiledCliProvider, +} from '../../src/main/ai/providers/ProfiledCliProvider'; + +// Opt-in contract test against a real CLI, with a loopback model and no cloud requests. +describe.skipIf(process.env.MARKUPRPLUS_TEST_COPILOT !== '1')('Copilot CLI isolation contract', () => { + it('exposes no tools and sends private context and images without loading user extensions', async () => { + const home = await mkdtemp(join(tmpdir(), 'markuprplus-copilot-contract-')); + const marker = join(home, 'must-not-exist'); + const analysis = { + summary: 'Synthetic report with a deliberately long summary that exceeds terminal width. '.repeat(4).trim(), + items: [], + themes: [], + positiveNotes: [], + metadata: { totalItems: 0, criticalCount: 0, highCount: 0 }, + }; + const requests: Record[] = []; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = z.record(z.string(), z.unknown()).parse(JSON.parse(Buffer.concat(chunks).toString())); + requests.push(body); + const content = JSON.stringify(analysis); + if (body.stream) { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.end([ + `data: ${JSON.stringify({ id: 'synthetic', object: 'chat.completion.chunk', choices: [ + { index: 0, delta: { role: 'assistant', content }, finish_reason: null }, + ] })}\n\n`, + `data: ${JSON.stringify({ id: 'synthetic', object: 'chat.completion.chunk', choices: [ + { index: 0, delta: {}, finish_reason: 'stop' }, + ] })}\n\n`, + 'data: [DONE]\n\n', + ].join('')); + } else { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ + id: 'synthetic', object: 'chat.completion', model: 'synthetic-model', + choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], + })); + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing loopback address'); + await writeFile(join(home, 'config.json'), JSON.stringify({ + hooks: { sessionStart: [{ type: 'command', bash: `touch "${marker}"` }] }, + })); + await writeFile(join(home, 'mcp-config.json'), JSON.stringify({ + mcpServers: { forbidden: { command: 'touch', args: [marker], tools: ['*'] } }, + })); + const profile = CLI_PROVIDER_PROFILES.find(({ id }) => id === 'github-copilot-cli')!; + const provider = new ProfiledCliProvider(profile, { + env: { + ...process.env, + COPILOT_HOME: home, + COPILOT_OFFLINE: 'true', + COPILOT_PROVIDER_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + COPILOT_PROVIDER_TYPE: 'openai', + COPILOT_PROVIDER_WIRE_API: 'completions', + COPILOT_PROVIDER_API_KEY: undefined, + COPILOT_PROVIDER_BEARER_TOKEN: undefined, + COPILOT_PROVIDER_HEADERS: undefined, + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + }, + }); + const session: Session = { + id: 'synthetic-session', + startTime: 1_700_000_000_000, + state: 'complete', + sourceId: 'screen:0:0', + feedbackItems: [], + transcriptBuffer: [{ + text: 'Synthetic feedback: the submit button is clipped.', + isFinal: true, confidence: 1, timestamp: 1_700_000_001, tier: 'whisper', + }], + screenshotBuffer: [{ + id: 'synthetic-image', timestamp: 1_700_000_001_000, width: 1, height: 1, + buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZWQAAAAASUVORK5CYII=', 'base64'), + }], + }; + await expect(provider.analyze(session, 'synthetic-model')).resolves.toEqual(analysis); + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) expect(request.tools ?? []).toEqual([]); + const input = JSON.stringify(requests); + expect(input.includes('Synthetic feedback: the submit button is clipped.')).toBe(true); + expect(input.includes('data:image/png;base64,')).toBe(true); + await expect(access(marker)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await rm(home, { recursive: true, force: true }); + } + }, 200_000); +}); diff --git a/tests/unit/ai/analysisProviderRegistry.test.ts b/tests/unit/ai/analysisProviderRegistry.test.ts index 99197af..1f9197b 100644 --- a/tests/unit/ai/analysisProviderRegistry.test.ts +++ b/tests/unit/ai/analysisProviderRegistry.test.ts @@ -104,6 +104,7 @@ describe('AnalysisProviderRegistry', () => { expect([ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -114,6 +115,7 @@ describe('AnalysisProviderRegistry', () => { ].map((id) => registry.get(id as never))).toMatchObject([ { id: 'codex-cli', name: 'Codex CLI', connection: 'cli' }, { id: 'claude-cli', name: 'Claude Code CLI', connection: 'cli' }, + { id: 'github-copilot-cli', name: 'GitHub Copilot CLI', connection: 'cli' }, { id: 'opencode-cli', name: 'OpenCode', connection: 'cli' }, { id: 'cursor-cli', name: 'Cursor Agent CLI', connection: 'cli' }, { id: 'qwen-cli', name: 'Qwen Code', connection: 'cli' }, @@ -139,6 +141,7 @@ describe('AnalysisProviderRegistry', () => { expect(registry.get('codex-cli')).toBeInstanceOf(BridgeCliProvider); expect(registry.get('claude-cli')).toBeInstanceOf(BridgeCliProvider); + expect(registry.get('github-copilot-cli')).toBeInstanceOf(BridgeCliProvider); expect(registry.get('opencode-cli')).toBeInstanceOf(BridgeCliProvider); expect(registry.get('ollama')).toMatchObject({ id: 'ollama' }); expect(registry.get('lmstudio')).toMatchObject({ id: 'lmstudio' }); @@ -155,6 +158,7 @@ describe('AnalysisProviderRegistry', () => { }); expect(registry.get('codex-cli')).not.toBeInstanceOf(BridgeCliProvider); + expect(registry.get('github-copilot-cli')).not.toBeInstanceOf(BridgeCliProvider); expect(registry.get('opencode-cli')).not.toBeInstanceOf(BridgeCliProvider); }); }); diff --git a/tests/unit/ai/profiledCliProvider.test.ts b/tests/unit/ai/profiledCliProvider.test.ts index a47ab22..70a0035 100644 --- a/tests/unit/ai/profiledCliProvider.test.ts +++ b/tests/unit/ai/profiledCliProvider.test.ts @@ -1,4 +1,6 @@ -import { readFile } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import type { Session } from '../../../src/main/SessionController'; import type { CliProcessOptions, CliProcessResult } from '../../../src/main/ai/CliProcessRunner'; @@ -47,15 +49,14 @@ function result(overrides: Partial = {}): CliProcessResult { describe('CLI provider profiles', () => { it('does not register headless CLIs whose tool restrictions can be bypassed', () => { expect(CLI_PROVIDER_PROFILES.map(({ id }) => id)).not.toContain('gemini-cli'); - expect(CLI_PROVIDER_PROFILES.map(({ id }) => id)).not.toContain('github-copilot-cli'); }); it('does not advertise excluded headless CLIs as available providers', async () => { const readme = await readFile('README.md', 'utf8'); expect(readme).not.toContain('| **Gemini CLI**'); - expect(readme).not.toContain('| **GitHub Copilot CLI**'); - expect(readme).toContain('Codex CLI and OpenCode can receive captured screenshots.'); + expect(readme).toContain('| **GitHub Copilot CLI**'); + expect(readme).toContain('Codex CLI, GitHub Copilot CLI, and OpenCode can receive captured screenshots.'); }); it('defines every additional CLI with a unique executable and safe invocation', () => { @@ -64,6 +65,7 @@ describe('CLI provider profiles', () => { name, executable: executables[0], }))).toEqual([ + { id: 'github-copilot-cli', name: 'GitHub Copilot CLI', executable: 'copilot' }, { id: 'opencode-cli', name: 'OpenCode', executable: 'opencode2' }, { id: 'cursor-cli', name: 'Cursor Agent CLI', executable: 'agent' }, { id: 'qwen-cli', name: 'Qwen Code', executable: 'qwen' }, @@ -99,7 +101,7 @@ describe('CLI provider profiles', () => { }); it('discovers an executable from PATH and reports its version', async () => { - const profile = CLI_PROVIDER_PROFILES[0]; + const profile = CLI_PROVIDER_PROFILES.find(({ id }) => id === 'opencode-cli')!; const run = vi.fn(async (options: CliProcessOptions) => { expect(options.executable).toBe('/tools/opencode2'); expect(options.args).toEqual(['--version']); @@ -124,6 +126,131 @@ describe('CLI provider profiles', () => { })); }); + describe('GitHub Copilot CLI report generation', () => { + function provider(run: (options: CliProcessOptions) => Promise, home = '/missing') { + const profile = CLI_PROVIDER_PROFILES.find(({ id }) => id === 'github-copilot-cli'); + expect(profile).toBeDefined(); + return new ProfiledCliProvider(profile!, { + env: { PATH: '/tools', COPILOT_HOME: home }, + homeDirectory: '/home/tester', + isExecutable: async (path) => path === '/tools/copilot', + realpath: async (path) => path, + run, + }); + } + + it.each(['1.0.82', '0.0.400', 'unknown'])('rejects unsupported version %s', async (version) => { + const copilot = provider(async () => result({ stdout: `GitHub Copilot CLI ${version}.` })); + await expect(copilot.discover()).resolves.toMatchObject({ + installed: true, + ready: false, + diagnostic: expect.stringContaining('1.0.83'), + }); + }); + + it('preserves login identity and default model without loading user tools, hooks or permissions', async () => { + const home = await mkdtemp(join(tmpdir(), 'markuprplus-copilot-test-')); + const account = { host: 'https://github.com', login: 'tester' }; + let reportDirectory = ''; + try { + const sourceConfig = JSON.stringify({ + loggedInUsers: [account], + lastLoggedInUser: account, + installedPlugins: [{ name: 'unsafe-plugin' }], + trustedFolders: ['/'], + hooks: { sessionStart: [{ command: 'must-not-run' }] }, + }); + await writeFile(join(home, 'config.json'), `// Copilot uses JSONC\n${sourceConfig}`); + await writeFile(join(home, 'settings.json'), `{ + "model": "gpt-5-mini", + "enabledPlugins": { "unsafe-plugin": true }, + "disableAllHooks": false, + }`); + const copilot = provider(async (options) => { + if (options.args[0] === '--version') return result({ stdout: 'GitHub Copilot CLI 1.0.83.' }); + reportDirectory = options.cwd!; + expect(options.args).toEqual(expect.arrayContaining([ + '--excluded-tools=builtin:*,mcp:*,custom:*', '--disable-builtin-mcps', '--no-custom-instructions', + '--no-ask-user', '--no-auto-update', '--no-remote', '--no-remote-export', + '--silent', '--stream', 'off', '--model', 'gpt-5.4', + '--output-format', 'json', + ])); + expect(options.args.join(' ')).not.toContain('The primary action is hard to find.'); + expect(options.args).not.toContain('-p'); + expect(options.args).not.toContain('--prompt'); + expect(options.stdin).toContain('The primary action is hard to find.'); + const isolatedHome = options.env!.COPILOT_HOME!; + expect(isolatedHome).toBe(join(reportDirectory, 'copilot-home')); + const config = JSON.parse(await readFile(join(isolatedHome, 'config.json'), 'utf8')); + expect(config).toEqual({ loggedInUsers: [account], lastLoggedInUser: account }); + const settings = JSON.parse(await readFile(join(isolatedHome, 'settings.json'), 'utf8')); + expect(settings).toMatchObject({ + model: 'gpt-5-mini', + disableAllHooks: true, + memory: false, + 'ide': { autoConnect: false }, + }); + expect(settings.enabledPlugins).toBeUndefined(); + expect((await stat(join(isolatedHome, 'config.json'))).mode & 0o777).toBe(0o600); + return result({ stdout: JSON.stringify(analysis) }); + }, home); + await expect(copilot.analyze(session, 'gpt-5.4')).resolves.toEqual(analysis); + await expect(access(reportDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(home, 'config.json'), 'utf8')).toBe(`// Copilot uses JSONC\n${sourceConfig}`); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('attaches screenshots in index order even without a transcript', async () => { + const copilot = provider(async (options) => { + if (options.args[0] === '--version') return result({ stdout: '1.0.83' }); + const imagePaths = options.args.flatMap((arg, index) => ( + arg === '--attachment' ? [options.args[index + 1]] : [] + )); + expect(imagePaths).toEqual([ + join(options.cwd!, 'screenshot-000.png'), + join(options.cwd!, 'screenshot-001.png'), + ]); + expect(await readFile(imagePaths[0])).toEqual(Buffer.from('first-image')); + expect(await readFile(imagePaths[1])).toEqual(Buffer.from('second-image')); + expect(options.stdin).toContain('Screenshot 1 was captured at 00:02.'); + return result({ stdout: JSON.stringify(analysis) }); + }); + await expect(copilot.analyze({ + ...session, + transcriptBuffer: [], + screenshotBuffer: ['first-image', 'second-image'].map((image, index) => ({ + id: `shot-${index}`, timestamp: session.startTime + (index + 1) * 1000, + buffer: Buffer.from(image), width: 10, height: 10, + })), + })).resolves.toEqual(analysis); + }); + + it('surfaces invalid user configuration rather than silently ignoring it', async () => { + const home = await mkdtemp(join(tmpdir(), 'markuprplus-copilot-test-')); + try { + await writeFile(join(home, 'config.json'), '{broken'); + const run = vi.fn(async () => result({ stdout: '1.0.83' })); + await expect(provider(run, home).analyze(session)).rejects.toThrow(/Copilot.*config.json/); + expect(run).toHaveBeenCalledTimes(1); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('surfaces sign-in failures and removes temporary context on failure', async () => { + let directory = ''; + const copilot = provider(async (options) => { + if (options.args[0] === '--version') return result({ stdout: '1.0.83' }); + directory = options.cwd!; + return result({ exitCode: 1, stderr: 'Authentication required. Run copilot login.' }); + }); + await expect(copilot.analyze(session)).rejects.toThrow(/copilot login/); + await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + it('returns an actionable diagnostic when a CLI is not installed', async () => { const profile = CLI_PROVIDER_PROFILES.find(({ id }) => id === 'opencode-cli')!; const provider = new ProfiledCliProvider(profile, { @@ -379,6 +506,35 @@ describe('CLI provider profiles', () => { }); describe('extractCliAnalysisResult', () => { + it('extracts the final Copilot assistant message only after a successful completion', () => { + const output = [ + { type: 'user.message', data: { content: 'Ignore echoed input' } }, + { type: 'assistant.message', data: { content: JSON.stringify(analysis), toolRequests: [] } }, + { type: 'assistant.turn_end', data: {} }, + { type: 'result', exitCode: 0, usage: {} }, + ].map((event) => JSON.stringify(event)).join('\n'); + expect(extractCliAnalysisResult(output)).toEqual(analysis); + }); + + it.each([ + { completion: [] }, + { completion: [{ type: 'result', exitCode: 1 }] }, + ])('rejects Copilot messages without successful completion', ({ completion }) => { + const output = [ + { type: 'assistant.message', data: { content: JSON.stringify(analysis) } }, + ...completion, + ].map((event) => JSON.stringify(event)).join('\n'); + expect(() => extractCliAnalysisResult(output)).toThrow('Invalid structured CLI output'); + }); + + it('rejects a Copilot message that requests tools rather than finishing the report', () => { + const output = [ + { type: 'assistant.message', data: { content: JSON.stringify(analysis), toolRequests: [{ name: 'bash' }] } }, + { type: 'result', exitCode: 0 }, + ].map((event) => JSON.stringify(event)).join('\n'); + expect(() => extractCliAnalysisResult(output)).toThrow('Invalid structured CLI output'); + }); + it.each([ JSON.stringify(analysis), `\`\`\`json\n${JSON.stringify(analysis)}\n\`\`\``, diff --git a/tests/unit/analysisProviderIpc.test.ts b/tests/unit/analysisProviderIpc.test.ts index d577e67..f038e5b 100644 --- a/tests/unit/analysisProviderIpc.test.ts +++ b/tests/unit/analysisProviderIpc.test.ts @@ -14,6 +14,7 @@ import type { AnalysisProviderAdapter } from '../../src/main/ai/providers/types' const providerIds = [ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -91,6 +92,7 @@ describe('analysis provider IPC', () => { expect(statuses.map(({ id }) => id)).toEqual([ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -124,6 +126,7 @@ describe('analysis provider IPC', () => { 'anthropic-api', 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -159,6 +162,9 @@ describe('analysis provider IPC', () => { await expect(handler({}, 'ollama', true)).resolves.toEqual([ { id: 'ollama-model', name: 'ollama model', source: 'discovered' }, ]); + await expect(handler({}, 'github-copilot-cli', true)).resolves.toEqual([ + { id: 'github-copilot-cli-model', name: 'github-copilot-cli model', source: 'discovered' }, + ]); expect(adapters.find(({ id }) => id === 'ollama')?.discover).toHaveBeenCalledWith(true); expect(adapters.find(({ id }) => id === 'codex-cli')?.discover).not.toHaveBeenCalled(); await expect(handler({}, 'rules', false)).rejects.toThrow('Provider does not expose report models'); diff --git a/tests/unit/analysisProviderOptions.test.ts b/tests/unit/analysisProviderOptions.test.ts index 1faa913..52cc32a 100644 --- a/tests/unit/analysisProviderOptions.test.ts +++ b/tests/unit/analysisProviderOptions.test.ts @@ -14,6 +14,7 @@ describe('analysis provider options', () => { expect(PROVIDER_OPTIONS.map(({ id }) => id)).toEqual([ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -27,7 +28,7 @@ describe('analysis provider options', () => { 'rules', ]); expect(PROVIDER_OPTIONS.map(({ connectionBadge }) => connectionBadge)).toEqual([ - 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', + 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'CLI', 'Local', 'Local', 'Cloud', 'Local', ]); }); @@ -36,6 +37,7 @@ describe('analysis provider options', () => { expect(providerOptionsForDistribution('mas').map(({ id }) => id)).toEqual([ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -64,6 +66,7 @@ describe('analysis provider options', () => { expect(getModelControlMode('lmstudio')).toBe('discovered-only'); expect(getModelControlMode('codex-cli')).toBe('default-or-custom'); expect(getModelControlMode('claude-cli')).toBe('default-or-custom'); + expect(getModelControlMode('github-copilot-cli')).toBe('default-or-custom'); expect(getModelControlMode('anthropic-api')).toBe('default-or-custom'); expect(getModelControlMode('amp-cli')).toBe('none'); expect(getModelControlMode('kiro-cli')).toBe('none'); @@ -73,6 +76,7 @@ describe('analysis provider options', () => { it('uses provider-specific default model labels', () => { expect(getModelDefaultLabel('codex-cli')).toBe('Codex default'); expect(getModelDefaultLabel('claude-cli')).toBe('Claude Code default'); + expect(getModelDefaultLabel('github-copilot-cli')).toBe('GitHub Copilot default'); expect(getModelDefaultLabel('anthropic-api')).toBe('Anthropic default'); }); diff --git a/tests/unit/analysisProviderSettings.test.ts b/tests/unit/analysisProviderSettings.test.ts index 44f79cf..514b347 100644 --- a/tests/unit/analysisProviderSettings.test.ts +++ b/tests/unit/analysisProviderSettings.test.ts @@ -16,7 +16,7 @@ describe('analysis provider settings', () => { expect(normalizeAnalysisProvider('anthropic')).toBe('anthropic-api'); expect(normalizeAnalysisProvider('rules')).toBe('rules'); expect(normalizeAnalysisProvider('gemini-cli')).toBe('anthropic-api'); - expect(normalizeAnalysisProvider('github-copilot-cli')).toBe('anthropic-api'); + expect(normalizeAnalysisProvider('github-copilot-cli')).toBe('github-copilot-cli'); expect(normalizeAnalysisProvider('unsupported')).toBe('anthropic-api'); }); @@ -26,6 +26,7 @@ describe('analysis provider settings', () => { 'anthropic-api', 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -58,8 +59,11 @@ describe('analysis provider settings', () => { settings.set('analysisProvider', 'opencode-cli'); expect(settings.get('analysisProvider')).toBe('opencode-cli'); + settings.set('analysisProvider', 'github-copilot-cli'); + expect(settings.get('analysisProvider')).toBe('github-copilot-cli'); + settings.set('analysisProvider', 'unsupported' as never); - expect(settings.get('analysisProvider')).toBe('opencode-cli'); + expect(settings.get('analysisProvider')).toBe('github-copilot-cli'); settings.set('analysisProvider', 'rules'); expect(settings.get('analysisProvider')).toBe('rules'); @@ -72,6 +76,7 @@ describe('analysis provider settings', () => { ollama: 'qwen2.5:7b', 'codex-cli': 'gpt-5.6-terra', 'claude-cli': 'sonnet', + 'github-copilot-cli': 'gpt-5-mini', } as const; settings.set('analysisModelsByProvider', selections); diff --git a/tests/unit/cliBridgeProtocol.test.ts b/tests/unit/cliBridgeProtocol.test.ts index 1dad1a6..8f070b7 100644 --- a/tests/unit/cliBridgeProtocol.test.ts +++ b/tests/unit/cliBridgeProtocol.test.ts @@ -41,6 +41,7 @@ describe('CLI bridge protocol', () => { expect(CLI_BRIDGE_PROVIDER_IDS).toEqual([ 'codex-cli', 'claude-cli', + 'github-copilot-cli', 'opencode-cli', 'cursor-cli', 'qwen-cli', @@ -57,6 +58,10 @@ describe('CLI bridge protocol', () => { }); expect(() => parseBridgeAnalyzeRequest(request({ provider: 'ollama' as never }))) .toThrow(/unsupported provider/i); + expect(parseBridgeAnalyzeRequest(request({ + provider: 'github-copilot-cli', + modelId: 'gpt-5-mini', + }))).toMatchObject({ provider: 'github-copilot-cli', modelId: 'gpt-5-mini' }); }); it('rejects unknown properties and unsafe model identifiers', () => { From e403bfdc6ba577f75ca366e78b81a37b7b79de58 Mon Sep 17 00:00:00 2001 From: hashfunction Date: Wed, 16 Sep 2026 09:39:29 -0700 Subject: [PATCH 2/3] feat(i18n): default to English with selectable interface language Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 ++ src/main/settings/SettingsManager.ts | 15 +- src/preload/index.ts | 7 + .../components/CrashRecoveryDialog.tsx | 4 +- src/renderer/components/SessionHistory.tsx | 4 +- src/renderer/components/SessionReview.tsx | 2 +- .../components/TranscriptionPreview.tsx | 1 + src/renderer/components/WindowSelector.tsx | 2 +- .../components/settings/GeneralTab.tsx | 13 +- .../components/settings/useSettingsPanel.ts | 9 +- src/renderer/i18n/UiLocalizer.ts | 94 +++++++++++ src/renderer/i18n/catalogs.ts | 24 +++ src/renderer/i18n/connectUiLanguage.ts | 28 ++++ src/renderer/main.tsx | 6 +- src/renderer/traditionalChinese.ts | 101 ++---------- src/renderer/types/electron.d.ts | 2 + src/shared/types.ts | 4 + src/shared/uiLanguage.ts | 15 ++ tests/ui/language-electron.spec.ts | 151 ++++++++++++++++++ tests/unit/uiLanguage.test.ts | 89 +++++++++++ tests/unit/uiLanguagePreload.test.ts | 29 ++++ tests/unit/uiLanguageSettings.test.ts | 51 ++++++ 22 files changed, 564 insertions(+), 104 deletions(-) create mode 100644 src/renderer/i18n/UiLocalizer.ts create mode 100644 src/renderer/i18n/catalogs.ts create mode 100644 src/renderer/i18n/connectUiLanguage.ts create mode 100644 src/shared/uiLanguage.ts create mode 100644 tests/ui/language-electron.spec.ts create mode 100644 tests/unit/uiLanguage.test.ts create mode 100644 tests/unit/uiLanguagePreload.test.ts create mode 100644 tests/unit/uiLanguageSettings.test.ts diff --git a/README.md b/README.md index c362c7c..e1f247c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,23 @@ Marked issues are numbered `MX-001…`; items that come from narration alone are +## Interface Language + +The desktop interface defaults to **English**, including when upgrading from an +installation that previously displayed Traditional Chinese automatically. +Choose **Settings > General > Language > Interface language** to opt into +**Traditional Chinese** or switch back to English. The preference is saved and +applies immediately across open windows without restarting or interrupting a +recording. Resetting General settings restores English. + +Interface language is separate from transcription language. Feedback text, +recordings, and generated reports are not translated by this setting. + +To add another interface language, register its identifier and display name in +`src/shared/uiLanguage.ts` and its translation catalog in +`src/renderer/i18n/catalogs.ts`. English labels are the source and fallback for +missing translations; mark user-content regions with `translate="no"`. + ## Report Providers Pick the model that turns a capture into a structured report. MarkuprPlus checks each one **before** you record and shows you what it actually found. diff --git a/src/main/settings/SettingsManager.ts b/src/main/settings/SettingsManager.ts index 6c4fb05..8566e6f 100644 --- a/src/main/settings/SettingsManager.ts +++ b/src/main/settings/SettingsManager.ts @@ -18,7 +18,7 @@ import Store from 'electron-store'; import * as keytar from 'keytar'; -import { app, ipcMain, safeStorage } from 'electron'; +import { app, BrowserWindow, ipcMain, safeStorage } from 'electron'; import { join } from 'path'; import { chmod } from 'fs/promises'; import { @@ -35,6 +35,7 @@ import { } from '../migration/LegacyBrandMigration'; import { isElectronTestHarnessAllowed } from '../e2e/ElectronTestHarness'; import { PUBLIC_BRAND_NAME } from '../../shared/publicBrand'; +import { DEFAULT_UI_LANGUAGE, UI_LANGUAGES, isUiLanguage } from '../../shared/uiLanguage'; // AppSettings is imported from '../../shared/types' (single source of truth) @@ -100,6 +101,7 @@ const DEFAULT_HOTKEY_CONFIG: HotkeyConfig = { */ const DEFAULT_SETTINGS: AppSettings = { // General + uiLanguage: DEFAULT_UI_LANGUAGE, outputDirectory: '', // Set dynamically in constructor launchAtLogin: false, checkForUpdates: true, @@ -146,6 +148,7 @@ const DEFAULT_SETTINGS: AppSettings = { * Schema for electron-store validation */ const SETTINGS_SCHEMA = { + uiLanguage: { type: 'string', enum: UI_LANGUAGES.map(({ id }) => id) }, outputDirectory: { type: 'string' }, launchAtLogin: { type: 'boolean' }, checkForUpdates: { type: 'boolean' }, @@ -314,6 +317,9 @@ export class SettingsManager implements ISettingsManager { */ private validateSetting(key: K, value: AppSettings[K]): boolean { switch (key) { + case 'uiLanguage': + return isUiLanguage(value); + case 'pauseThreshold': return typeof value === 'number' && value >= 500 && value <= 3000; @@ -613,6 +619,13 @@ export class SettingsManager implements ISettingsManager { * Emit a change event to all subscribers */ private emitChange(key: string, newValue: unknown, oldValue: unknown): void { + if (key === 'uiLanguage' && isUiLanguage(newValue)) { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.UI_LANGUAGE_CHANGED, newValue); + } + } + } for (const callback of this.changeCallbacks) { try { callback(key, newValue, oldValue); diff --git a/src/preload/index.ts b/src/preload/index.ts index e71430a..bda9342 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ */ import { contextBridge, ipcRenderer } from 'electron'; +import type { UiLanguage } from '../shared/uiLanguage'; import { IPC_CHANNELS, type AppSettings, @@ -535,6 +536,12 @@ const markuprxApi = { // Settings API // =========================================================================== settings: { + onUiLanguageChange: (callback: (language: UiLanguage) => void): Unsubscribe => { + const handler = (_event: Electron.IpcRendererEvent, language: UiLanguage) => callback(language); + ipcRenderer.on(IPC_CHANNELS.UI_LANGUAGE_CHANGED, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.UI_LANGUAGE_CHANGED, handler); + }, + /** * Get a specific setting */ diff --git a/src/renderer/components/CrashRecoveryDialog.tsx b/src/renderer/components/CrashRecoveryDialog.tsx index 569acf1..2e377dc 100644 --- a/src/renderer/components/CrashRecoveryDialog.tsx +++ b/src/renderer/components/CrashRecoveryDialog.tsx @@ -303,7 +303,7 @@ export function CrashRecoveryDialog({ marginBottom: i < arr.length - 1 ? 8 : 0, }}> {row.label}: - )} - {item.text || '[No text]'} + {item.text || '[No text]'} ))} {session.feedbackItems.length > 3 && ( diff --git a/src/renderer/components/SessionHistory.tsx b/src/renderer/components/SessionHistory.tsx index 6b2004e..d43716d 100644 --- a/src/renderer/components/SessionHistory.tsx +++ b/src/renderer/components/SessionHistory.tsx @@ -230,7 +230,7 @@ const SessionCard: React.FC = ({ {/* Content */}
- {session.sourceName || 'Untitled Session'} + {session.sourceName || 'Untitled Session'} {formatRelativeDate(session.startTime)}
@@ -260,7 +260,7 @@ const SessionCard: React.FC = ({
{session.transcriptionPreview && ( -

+

{session.transcriptionPreview.slice(0, 80)} {session.transcriptionPreview.length > 80 ? '...' : ''}

diff --git a/src/renderer/components/SessionReview.tsx b/src/renderer/components/SessionReview.tsx index acc2e9d..4900d97 100644 --- a/src/renderer/components/SessionReview.tsx +++ b/src/renderer/components/SessionReview.tsx @@ -463,7 +463,7 @@ const FeedbackItemCard: React.FC = ({ placeholder="Enter feedback text..." /> ) : ( -

{item.transcription}

+

{item.transcription}

)} {/* Screenshot Thumbnails */} diff --git a/src/renderer/components/TranscriptionPreview.tsx b/src/renderer/components/TranscriptionPreview.tsx index 16cab58..fca6db4 100644 --- a/src/renderer/components/TranscriptionPreview.tsx +++ b/src/renderer/components/TranscriptionPreview.tsx @@ -157,6 +157,7 @@ export const TranscriptionPreview: React.FC = ({ {formatTimestamp(segment.startTime)}

= ({

{/* Name with resolution info for screens */} -
+
{source.type === 'screen' ? ( {source.display?.label || source.name} diff --git a/src/renderer/components/settings/GeneralTab.tsx b/src/renderer/components/settings/GeneralTab.tsx index d130e22..ff5ba1d 100644 --- a/src/renderer/components/settings/GeneralTab.tsx +++ b/src/renderer/components/settings/GeneralTab.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { PUBLIC_BRAND_NAME } from '../../../shared/publicBrand'; import type { AppSettings } from '../../../shared/types'; -import { SettingsSection, ToggleSetting, DirectoryPicker } from '../primitives'; +import { UI_LANGUAGES, normalizeUiLanguage } from '../../../shared/uiLanguage'; +import { SettingsSection, ToggleSetting, DirectoryPicker, DropdownSetting } from '../primitives'; import { styles } from './settingsStyles'; export const GeneralTab: React.FC<{ @@ -10,6 +11,16 @@ export const GeneralTab: React.FC<{ onResetSection: () => void; }> = ({ settings, onSettingChange, onResetSection }) => (
+ + ({ value: id, label: name }))} + onChange={(value) => onSettingChange('uiLanguage', normalizeUiLanguage(value))} + /> + + void, initialTa // Load settings on mount // --------------------------------------------------------------------------- + useEffect(() => window.markuprx.settings.onUiLanguageChange((uiLanguage) => { + setSettings((previous) => ({ ...previous, uiLanguage })); + }), []); + useEffect(() => { if (!isOpen) return; @@ -191,7 +195,7 @@ export function useSettingsPanel(isOpen: boolean, onClose: () => void, initialTa const handleSettingChange = useCallback( async (key: K, value: AppSettings[K]) => { - setSettings((prev) => ({ ...prev, [key]: value })); + if (key !== 'uiLanguage') setSettings((prev) => ({ ...prev, [key]: value })); setSaveStatus('saving'); setSaveError(null); try { @@ -471,11 +475,12 @@ export function useSettingsPanel(isOpen: boolean, onClose: () => void, initialTa const resetGeneralSection = useCallback(async () => { const defaults = { + uiLanguage: DEFAULT_SETTINGS.uiLanguage, outputDirectory: DEFAULT_SETTINGS.outputDirectory, launchAtLogin: DEFAULT_SETTINGS.launchAtLogin, checkForUpdates: DEFAULT_SETTINGS.checkForUpdates, }; - setSettings((prev) => ({ ...prev, ...defaults })); + setSettings((prev) => ({ ...prev, ...defaults, uiLanguage: prev.uiLanguage })); setSaveStatus('saving'); setSaveError(null); try { diff --git a/src/renderer/i18n/UiLocalizer.ts b/src/renderer/i18n/UiLocalizer.ts new file mode 100644 index 0000000..b88df0f --- /dev/null +++ b/src/renderer/i18n/UiLocalizer.ts @@ -0,0 +1,94 @@ +import { DEFAULT_UI_LANGUAGE, normalizeUiLanguage } from '../../shared/uiLanguage'; +import { translateUiText } from './catalogs'; + +const ATTRIBUTE_NAMES = ['title', 'aria-label', 'placeholder', 'data-tooltip-content']; +const SKIP_TEXT = 'script,style,textarea,input,pre,[contenteditable],[translate="no"]'; +const SKIP_ATTRIBUTES = 'script,style,pre,[translate="no"]'; + +interface TranslatedValue { + source: string; + translated: string; +} + +export interface UiLocalizer { + setLanguage(language: unknown): void; + dispose(): void; +} + +export function createUiLocalizer(document: Document): UiLocalizer { + const textValues = new WeakMap(); + const attributeValues = new WeakMap>(); + let language = DEFAULT_UI_LANGUAGE; + let scheduled = false; + let disposed = false; + + function translatedValue(current: string, previous?: TranslatedValue): TranslatedValue { + // React may have updated this same node since the last translation pass. + const source = previous?.translated === current ? previous.source : current; + return { source, translated: translateUiText(source, language) }; + } + + function apply(): void { + if (disposed) return; + observer.disconnect(); + document.documentElement.lang = language; + if (document.body) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + if (node.parentElement && !node.parentElement.closest(SKIP_TEXT)) { + const value = translatedValue(node.nodeValue ?? '', textValues.get(node)); + textValues.set(node, value); + if (node.nodeValue !== value.translated) node.nodeValue = value.translated; + } + node = walker.nextNode(); + } + for (const element of [document.body, ...document.body.querySelectorAll('*')]) { + if (element.closest(SKIP_ATTRIBUTES)) continue; + const values = attributeValues.get(element) ?? new Map(); + for (const name of ATTRIBUTE_NAMES) { + const current = element.getAttribute(name); + if (current === null) { + values.delete(name); + continue; + } + const value = translatedValue(current, values.get(name)); + values.set(name, value); + if (current !== value.translated) element.setAttribute(name, value.translated); + } + attributeValues.set(element, values); + } + } + if (language !== 'en') { + observer.observe(document.documentElement, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ATTRIBUTE_NAMES, + }); + } + } + + const observer = new MutationObserver(() => { + if (scheduled || disposed) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + apply(); + }); + }); + + return { + setLanguage(value) { + language = normalizeUiLanguage(value); + apply(); + }, + dispose() { + language = DEFAULT_UI_LANGUAGE; + apply(); + disposed = true; + observer.disconnect(); + }, + }; +} diff --git a/src/renderer/i18n/catalogs.ts b/src/renderer/i18n/catalogs.ts new file mode 100644 index 0000000..e6a8acd --- /dev/null +++ b/src/renderer/i18n/catalogs.ts @@ -0,0 +1,24 @@ +import type { UiLanguage } from '../../shared/uiLanguage'; +import { traditionalChineseCatalog } from '../traditionalChinese'; + +export interface UiTranslationCatalog { + exact: Readonly>; + fragments: ReadonlyArray; +} + +const CATALOGS: Record, UiTranslationCatalog> = { + 'zh-TW': traditionalChineseCatalog, +}; + +export function translateUiText(value: string, language: UiLanguage): string { + if (language === 'en' || !value.trim()) return value; + const catalog = CATALOGS[language]; + const trimmed = value.trim(); + if (Object.prototype.hasOwnProperty.call(catalog.exact, trimmed)) { + return value.replace(trimmed, catalog.exact[trimmed]); + } + return catalog.fragments.reduce( + (translated, [source, target]) => translated.split(source).join(target), + value, + ); +} diff --git a/src/renderer/i18n/connectUiLanguage.ts b/src/renderer/i18n/connectUiLanguage.ts new file mode 100644 index 0000000..99205bb --- /dev/null +++ b/src/renderer/i18n/connectUiLanguage.ts @@ -0,0 +1,28 @@ +import { DEFAULT_UI_LANGUAGE, type UiLanguage } from '../../shared/uiLanguage'; +import type { UiLocalizer } from './UiLocalizer'; + +interface UiLanguageSettings { + get(key: 'uiLanguage'): Promise; + onUiLanguageChange(callback: (language: UiLanguage) => void): () => void; +} + +export function connectUiLanguage(settings: UiLanguageSettings, localizer: UiLocalizer): () => void { + let active = true; + let receivedChange = false; + localizer.setLanguage(DEFAULT_UI_LANGUAGE); + const unsubscribe = settings.onUiLanguageChange((language) => { + if (!active) return; + receivedChange = true; + localizer.setLanguage(language); + }); + void settings.get('uiLanguage').then((language) => { + if (active && !receivedChange) localizer.setLanguage(language); + }).catch((error: unknown) => { + console.error('[UiLanguage] Unable to load the saved interface language.', error); + }); + return () => { + active = false; + unsubscribe(); + localizer.dispose(); + }; +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index ac2ca8f..dea2306 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -9,7 +9,8 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import { ThemeProvider } from './components/ThemeProvider'; import { initAudioCapture, destroyAudioCapture } from './audio/AudioCaptureRenderer'; import CaptureOverlayApp from './overlays/CaptureOverlayApp'; -import { installTraditionalChineseUi } from './traditionalChinese'; +import { createUiLocalizer } from './i18n/UiLocalizer'; +import { connectUiLanguage } from './i18n/connectUiLanguage'; // Import global styles (includes CSS reset and theme utilities) import './styles/globals.css'; @@ -44,7 +45,8 @@ window.addEventListener('unhandledrejection', (event) => { }); const root = createRoot(container); -installTraditionalChineseUi(); +const disconnectUiLanguage = connectUiLanguage(window.markuprx.settings, createUiLocalizer(document)); +window.addEventListener('beforeunload', disconnectUiLanguage, { once: true }); root.render( {isCaptureOverlay ? ( diff --git a/src/renderer/traditionalChinese.ts b/src/renderer/traditionalChinese.ts index 5275e84..98f8750 100644 --- a/src/renderer/traditionalChinese.ts +++ b/src/renderer/traditionalChinese.ts @@ -1,13 +1,12 @@ /** - * Local Traditional Chinese UI overlay for the Windows build. - * - * MarkuprPlus 3.1.2 does not expose a renderer locale/i18n system. Keep this - * layer deliberately small and dependency-free: it translates visible UI - * labels after React renders them, while leaving the application logic and - * stored session data untouched. + * Optional Traditional Chinese interface translation catalog. + * English source labels are retained by the shared UI localizer. */ +import type { UiTranslationCatalog } from './i18n/catalogs'; const EXACT_TRANSLATIONS: Record = { + 'Interface language': '介面語言', + 'English by default. Changes apply immediately.': '預設為英文。變更會立即套用。', 'Recording Active': '正在錄製', 'Latest Report Path': '最新報告位置', 'Session Recording': '工作階段錄影', @@ -641,89 +640,7 @@ const FRAGMENT_TRANSLATIONS: Array<[string, string]> = [ [' to undo', ' 以復原'], ]; -function translateValue(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return value; - - const exact = EXACT_TRANSLATIONS[trimmed]; - if (exact) { - return value.replace(trimmed, exact); - } - - let translated = value; - for (const [source, target] of FRAGMENT_TRANSLATIONS) { - translated = translated.split(source).join(target); - } - return translated; -} - -function shouldSkipElement(element: Element): boolean { - const tag = element.tagName.toLowerCase(); - return tag === 'script' || tag === 'style' || tag === 'textarea' || tag === 'input' || tag === 'pre' || element.hasAttribute('contenteditable'); -} - -function translateTextNodes(root: Node): void { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - const nodes: Text[] = []; - let current: Node | null = walker.nextNode(); - while (current) { - nodes.push(current as Text); - current = walker.nextNode(); - } - - for (const node of nodes) { - const parent = node.parentElement; - if (!parent || shouldSkipElement(parent) || parent.closest('script,style,textarea,input,pre,[contenteditable="true"]')) { - continue; - } - const translated = translateValue(node.nodeValue ?? ''); - if (translated !== node.nodeValue) { - node.nodeValue = translated; - } - } -} - -function translateAttributes(root: Element): void { - const elements = [root, ...Array.from(root.querySelectorAll('*'))]; - for (const element of elements) { - const tag = element.tagName.toLowerCase(); - if (tag === 'script' || tag === 'style' || tag === 'pre') continue; - for (const name of ['title', 'aria-label', 'placeholder', 'data-tooltip-content']) { - const value = element.getAttribute(name); - if (!value) continue; - const translated = translateValue(value); - if (translated !== value) { - element.setAttribute(name, translated); - } - } - } -} - -export function installTraditionalChineseUi(): void { - if (document.documentElement.dataset.markuprplusTraditionalChinese === '1') return; - document.documentElement.dataset.markuprplusTraditionalChinese = '1'; - - let scheduled = false; - const translate = () => { - scheduled = false; - if (!document.body) return; - translateTextNodes(document.body); - translateAttributes(document.body); - }; - const schedule = () => { - if (scheduled) return; - scheduled = true; - queueMicrotask(translate); - }; - - const observer = new MutationObserver(schedule); - observer.observe(document.documentElement, { - subtree: true, - childList: true, - characterData: true, - attributes: true, - attributeFilter: ['title', 'aria-label', 'placeholder', 'data-tooltip-content'], - }); - - schedule(); -} +export const traditionalChineseCatalog: UiTranslationCatalog = { + exact: EXACT_TRANSLATIONS, + fragments: FRAGMENT_TRANSLATIONS, +}; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 815b467..c1f9c6e 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -46,6 +46,7 @@ import type { ReviewSession, } from '../../shared/types'; import type { ElectronTestAPI } from '../../shared/electronTestHarness'; +import type { UiLanguage } from '../../shared/uiLanguage'; type Unsubscribe = () => void; @@ -194,6 +195,7 @@ interface ProcessingAPI { * Settings API */ interface SettingsAPI { + onUiLanguageChange: (callback: (language: UiLanguage) => void) => Unsubscribe; get: (key: K) => Promise; getAll: () => Promise; set: (key: K, value: AppSettings[K]) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index e4cfe37..c690b99 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,7 @@ /** * Shared types for MarkuprX */ +import { DEFAULT_UI_LANGUAGE, type UiLanguage } from './uiLanguage'; /** * Represents a single screenshot captured during a feedback session @@ -245,6 +246,7 @@ export interface AnalysisProviderStatus { */ export interface AppSettings { // General + uiLanguage: UiLanguage; outputDirectory: string; launchAtLogin: boolean; checkForUpdates: boolean; @@ -333,6 +335,7 @@ export interface CliBridgePairResult { */ export const DEFAULT_SETTINGS: AppSettings = { // General + uiLanguage: DEFAULT_UI_LANGUAGE, outputDirectory: '', // Set dynamically by SettingsManager launchAtLogin: false, checkForUpdates: true, @@ -476,6 +479,7 @@ export const IPC_CHANNELS = { // Settings Channels (Renderer -> Main) // --------------------------------------------------------------------------- SETTINGS_GET: 'markuprx:settings:get', + UI_LANGUAGE_CHANGED: 'markuprx:ui-language-changed', SETTINGS_GET_ALL: 'markuprx:settings:get-all', SETTINGS_SET: 'markuprx:settings:set', SETTINGS_GET_API_KEY: 'markuprx:settings:get-api-key', diff --git a/src/shared/uiLanguage.ts b/src/shared/uiLanguage.ts new file mode 100644 index 0000000..549c91d --- /dev/null +++ b/src/shared/uiLanguage.ts @@ -0,0 +1,15 @@ +export const UI_LANGUAGES = [ + { id: 'en', name: 'English' }, + { id: 'zh-TW', name: '繁體中文 (Traditional Chinese)' }, +] as const; + +export type UiLanguage = typeof UI_LANGUAGES[number]['id']; +export const DEFAULT_UI_LANGUAGE: UiLanguage = 'en'; + +export function isUiLanguage(value: unknown): value is UiLanguage { + return UI_LANGUAGES.some(({ id }) => id === value); +} + +export function normalizeUiLanguage(value: unknown): UiLanguage { + return isUiLanguage(value) ? value : DEFAULT_UI_LANGUAGE; +} diff --git a/tests/ui/language-electron.spec.ts b/tests/ui/language-electron.spec.ts new file mode 100644 index 0000000..abfae55 --- /dev/null +++ b/tests/ui/language-electron.spec.ts @@ -0,0 +1,151 @@ +import { _electron as electron, expect, test, type ElectronApplication } from '@playwright/test'; +import { resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { createElectronHarnessEnvironment } from '../fixtures/electronHarness'; + +const root = resolve(import.meta.dirname, '../..'); + +async function showSettings(application: ElectronApplication) { + const page = await application.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await application.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('markuprx:show-settings'); + window.show(); + } + }); + await page.locator('#markuprx-settings-tab-general').click(); + return page; +} + +test('defaults to English and switches languages without reloading or changing user content', async () => { + const harness = await createElectronHarnessEnvironment(); + const application = await electron.launch({ args: [root], env: harness.env }); + try { + const page = await showSettings(application); + await expect(page.getByRole('tab', { name: 'General', exact: true })).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + const language = page.getByRole('combobox', { name: /Interface language|介面語言/ }); + await expect(language).toHaveValue('en'); + await page.evaluate(() => { + document.documentElement.dataset.languageTestSession = 'not-reloaded'; + const fixture = document.createElement('div'); + fixture.id = 'language-fixture'; + fixture.innerHTML = 'Settings' + + '
Settings
' + + '
Settings
' + + '
Settings
'; + document.body.append(fixture); + }); + await language.selectOption('zh-TW'); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh-TW'); + await expect(page.getByRole('tab', { name: '一般', exact: true })).toBeVisible(); + await expect(page.locator('#translated-copy')).toHaveText('設定'); + await expect(page.locator('#translated-copy')).toHaveAttribute('title', '開啟設定'); + await expect(page.locator('#language-fixture textarea')).toHaveValue('Settings'); + await expect(page.locator('#language-fixture textarea')).toHaveAttribute('placeholder', '開啟設定'); + await expect(page.locator('#language-fixture [contenteditable]')).toHaveText('Settings'); + await expect(page.locator('#language-fixture pre')).toHaveText('Settings'); + await expect(page.locator('#language-fixture [translate="no"]')).toHaveText('Settings'); + await expect(page.locator('#language-fixture [translate="no"]')).toHaveAttribute('title', 'Settings'); + await page.evaluate(() => { + const copy = document.querySelector('#translated-copy')!; + copy.firstChild!.nodeValue = 'Recording Active'; + copy.setAttribute('title', 'Stop Session'); + const newLabel = document.createElement('span'); + newLabel.id = 'new-label'; + newLabel.textContent = 'Open Settings'; + copy.append(newLabel); + }); + await expect(page.locator('#translated-copy')).toHaveText('正在錄製開啟設定'); + await expect(page.locator('#new-label')).toHaveText('開啟設定'); + await language.selectOption('en'); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(page.getByRole('tab', { name: 'General', exact: true })).toBeVisible(); + await expect(page.locator('#translated-copy')).toHaveText('Recording ActiveOpen Settings'); + await expect(page.locator('#translated-copy')).toHaveAttribute('title', 'Stop Session'); + await expect(page.locator('#language-fixture textarea')).toHaveAttribute('placeholder', 'Open Settings'); + await expect(page.locator('html')).toHaveAttribute('data-language-test-session', 'not-reloaded'); + await expect(language).toHaveValue('en'); + } finally { + await application.close(); + await harness.cleanup(); + } +}); + +test('persists the selected language across renderer reloads and synchronizes other windows', async () => { + const harness = await createElectronHarnessEnvironment(); + let application = await electron.launch({ args: [root], env: harness.env }); + try { + let page = await showSettings(application); + await page.getByRole('combobox', { name: /Interface language|介面語言/ }).selectOption('zh-TW'); + await expect.poll(() => page.evaluate(() => window.markuprx.settings.get('uiLanguage'))).toBe('zh-TW'); + await application.close(); + application = await electron.launch({ args: [root], env: harness.env }); + page = await showSettings(application); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh-TW'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh-TW'); + const siblingPromise = application.waitForEvent('window'); + await application.evaluate(async ({ BrowserWindow }, preload) => { + const source = BrowserWindow.getAllWindows()[0]; + const sibling = new BrowserWindow({ + show: false, + webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false }, + }); + const url = new URL(source.webContents.getURL()); + url.searchParams.set('overlay', 'capture'); + await sibling.loadURL(url.href); + }, resolve(root, 'dist/preload/index.cjs')); + const sibling = await siblingPromise; + await expect(sibling.locator('html')).toHaveAttribute('lang', 'zh-TW'); + await page.evaluate(() => window.markuprx.settings.set('uiLanguage', 'en')); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(sibling.locator('html')).toHaveAttribute('lang', 'en'); + await showSettings(application); + const language = page.getByRole('combobox', { name: /Interface language|介面語言/ }); + await expect(language).toHaveValue('en'); + await sibling.evaluate(() => window.markuprx.settings.set('uiLanguage', 'zh-TW')); + await expect(language).toHaveValue('zh-TW'); + await page.getByRole('button', { name: '將此區段重設為預設值', exact: true }).click(); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(sibling.locator('html')).toHaveAttribute('lang', 'en'); + await expect(language).toHaveValue('en'); + await expect.poll(() => page.evaluate(() => window.markuprx.settings.get('uiLanguage'))).toBe('en'); + } finally { + await application.close(); + await harness.cleanup(); + } +}); + +test('uses English for an upgraded profile without changing its transcription language', async () => { + const harness = await createElectronHarnessEnvironment(); + await writeFile(resolve(harness.userDataDir, 'settings.json'), JSON.stringify({ language: 'ja' })); + const application = await electron.launch({ args: [root], env: harness.env }); + try { + const page = await showSettings(application); + await expect(page.getByRole('combobox', { name: 'Interface language' })).toHaveValue('en'); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + expect(await page.evaluate(() => window.markuprx.settings.get('language'))).toBe('ja'); + } finally { + await application.close(); + await harness.cleanup(); + } +}); + +test('keeps English selected and reports a failed language save', async () => { + const harness = await createElectronHarnessEnvironment({ failSettingsKey: 'uiLanguage' }); + const application = await electron.launch({ args: [root], env: harness.env }); + try { + const page = await showSettings(application); + const language = page.getByRole('combobox', { name: /Interface language|介面語言/ }); + await language.selectOption('zh-TW'); + await expect(page.getByText('Unable to save', { exact: true })).toBeVisible(); + await expect(language).toHaveValue('en'); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect.poll(() => page.evaluate(() => window.markuprx.settings.get('uiLanguage'))).toBe('en'); + } finally { + await application.close(); + await harness.cleanup(); + } +}); diff --git a/tests/unit/uiLanguage.test.ts b/tests/unit/uiLanguage.test.ts new file mode 100644 index 0000000..158aa10 --- /dev/null +++ b/tests/unit/uiLanguage.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { normalizeUiLanguage, UI_LANGUAGES, type UiLanguage } from '../../src/shared/uiLanguage'; +import { translateUiText } from '../../src/renderer/i18n/catalogs'; +import { connectUiLanguage } from '../../src/renderer/i18n/connectUiLanguage'; +import { flushPromises } from '../setup'; + +describe('UI language catalogs', () => { + it('defaults missing or unsupported preferences to English without changing known choices', () => { + for (const value of [undefined, null, 'fr', 'EN', 42]) { + expect(normalizeUiLanguage(value)).toBe('en'); + } + for (const { id } of UI_LANGUAGES) expect(normalizeUiLanguage(id)).toBe(id); + }); + + it('preserves English and whitespace, and falls back to the source for missing translations', () => { + expect(translateUiText(' Settings ', 'en')).toBe(' Settings '); + expect(translateUiText(' Settings ', 'zh-TW')).toBe(' 設定 '); + expect(translateUiText(' \n ', 'zh-TW')).toBe(' \n '); + expect(translateUiText('Uncatalogued label', 'zh-TW')).toBe('Uncatalogued label'); + expect(translateUiText('toString', 'zh-TW')).toBe('toString'); + }); +}); + +describe('UI language subscription lifecycle', () => { + function setup() { + let resolve!: (value: UiLanguage) => void; + let reject!: (reason: Error) => void; + let notify!: (language: UiLanguage) => void; + const loaded = new Promise((accept, fail) => { resolve = accept; reject = fail; }); + const unsubscribe = vi.fn(); + const settings = { + get: vi.fn(() => loaded), + onUiLanguageChange: vi.fn((callback: typeof notify) => { + notify = callback; + return unsubscribe; + }), + }; + const localizer = { setLanguage: vi.fn(), dispose: vi.fn() }; + const cleanup = connectUiLanguage(settings, localizer); + return { settings, localizer, cleanup, resolve, reject, unsubscribe, notify, loaded }; + } + + it('starts in English and loads the saved preference', async () => { + const state = setup(); + expect(state.localizer.setLanguage).toHaveBeenCalledWith('en'); + expect(state.settings.get).toHaveBeenCalledWith('uiLanguage'); + state.resolve('zh-TW'); + await state.loaded; + await flushPromises(); + expect(state.localizer.setLanguage).toHaveBeenLastCalledWith('zh-TW'); + state.cleanup(); + }); + + it('does not overwrite a newer notification with an older initial read', async () => { + const state = setup(); + state.notify('zh-TW'); + state.resolve('en'); + await state.loaded; + await flushPromises(); + expect(state.localizer.setLanguage).toHaveBeenLastCalledWith('zh-TW'); + state.cleanup(); + }); + + it('unsubscribes and ignores late reads and notifications after disposal', async () => { + const state = setup(); + state.cleanup(); + state.resolve('zh-TW'); + state.notify('zh-TW'); + await state.loaded; + await flushPromises(); + expect(state.unsubscribe).toHaveBeenCalledOnce(); + expect(state.localizer.dispose).toHaveBeenCalledOnce(); + expect(state.localizer.setLanguage).toHaveBeenCalledTimes(1); + }); + + it('reports initial settings failures rather than hiding them', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + const state = setup(); + const error = new Error('settings unavailable'); + state.reject(error); + await expect(state.loaded).rejects.toThrow(error); + expect(errorLog).toHaveBeenCalledWith( + '[UiLanguage] Unable to load the saved interface language.', + error, + ); + state.cleanup(); + errorLog.mockRestore(); + }); +}); diff --git a/tests/unit/uiLanguagePreload.test.ts b/tests/unit/uiLanguagePreload.test.ts new file mode 100644 index 0000000..e11943b --- /dev/null +++ b/tests/unit/uiLanguagePreload.test.ts @@ -0,0 +1,29 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +let settings: { onUiLanguageChange(callback: (language: string) => void): () => void }; + +describe('interface language preload notifications', () => { + beforeAll(async () => { + await import('../../src/preload/index'); + const exposure = vi.mocked(contextBridge.exposeInMainWorld).mock.calls.find(([name]) => name === 'markuprx'); + if (!exposure) throw new Error('Missing preload API'); + settings = exposure[1].settings; + }); + + beforeEach(() => { + vi.mocked(ipcRenderer.on).mockClear(); + vi.mocked(ipcRenderer.removeListener).mockClear(); + }); + + it('exposes only the saved language and unsubscribes its exact listener', () => { + const callback = vi.fn(); + const unsubscribe = settings.onUiLanguageChange(callback); + const [channel, listener] = vi.mocked(ipcRenderer.on).mock.calls[0]; + expect(channel).toBe('markuprx:ui-language-changed'); + listener({ sender: 'not exposed' } as never, 'zh-TW'); + expect(callback).toHaveBeenCalledWith('zh-TW'); + unsubscribe(); + expect(ipcRenderer.removeListener).toHaveBeenCalledWith(channel, listener); + }); +}); diff --git a/tests/unit/uiLanguageSettings.test.ts b/tests/unit/uiLanguageSettings.test.ts new file mode 100644 index 0000000..07c41dc --- /dev/null +++ b/tests/unit/uiLanguageSettings.test.ts @@ -0,0 +1,51 @@ +import { BrowserWindow } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SETTINGS, IPC_CHANNELS } from '../../src/shared/types'; +import { SettingsManager } from '../../src/main/settings/SettingsManager'; + +describe('interface language settings', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(BrowserWindow, { getAllWindows: vi.fn(() => []) }); + }); + + it('defaults to English independently of transcription language', () => { + const settings = new SettingsManager(); + expect(DEFAULT_SETTINGS.uiLanguage).toBe('en'); + expect(settings.get('uiLanguage')).toBe('en'); + settings.set('language', 'ja'); + expect(settings.get('uiLanguage')).toBe('en'); + }); + + it('persists supported languages and rejects unknown locale identifiers', () => { + const settings = new SettingsManager(); + settings.set('uiLanguage', 'zh-TW'); + expect(settings.get('uiLanguage')).toBe('zh-TW'); + settings.set('uiLanguage', 'unsupported' as never); + expect(settings.get('uiLanguage')).toBe('zh-TW'); + settings.set('uiLanguage', 'en'); + expect(settings.get('uiLanguage')).toBe('en'); + }); + + it('notifies every live renderer, including capture overlays, after a saved language change', () => { + const main = new BrowserWindow(); + const overlay = new BrowserWindow(); + const destroyed = new BrowserWindow(); + vi.mocked(destroyed.isDestroyed).mockReturnValue(true); + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([main, overlay, destroyed]); + const settings = new SettingsManager(); + settings.set('uiLanguage', 'zh-TW'); + expect(main.webContents.send).toHaveBeenCalledWith(IPC_CHANNELS.UI_LANGUAGE_CHANGED, 'zh-TW'); + expect(overlay.webContents.send).toHaveBeenCalledWith(IPC_CHANNELS.UI_LANGUAGE_CHANGED, 'zh-TW'); + expect(destroyed.webContents.send).not.toHaveBeenCalled(); + }); + + it('does not broadcast invalid values or transcription-language changes', () => { + const window = new BrowserWindow(); + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([window]); + const settings = new SettingsManager(); + settings.set('uiLanguage', 'invalid' as never); + settings.set('language', 'zh'); + expect(window.webContents.send).not.toHaveBeenCalled(); + }); +}); From 6817ee138cea661c60b50a3e79aebca29925aff2 Mon Sep 17 00:00:00 2001 From: hashfunction Date: Wed, 16 Sep 2026 10:08:31 -0700 Subject: [PATCH 3/3] feat: download a local Whisper model automatically on startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 + src/main/index.ts | 10 + src/main/ipc/windowHandlers.ts | 61 ++-- .../transcription/ModelDownloadManager.ts | 300 ++++++++---------- .../components/settings/AdvancedTab.tsx | 19 +- .../components/settings/useSettingsPanel.ts | 37 ++- src/renderer/traditionalChinese.ts | 2 + src/shared/types.ts | 8 + tests/ui/whisper-startup-electron.spec.ts | 85 +++++ tests/unit/startupWhisperDownload.test.ts | 164 ++++++++++ tests/unit/whisperDownloadIpc.test.ts | 69 ++++ 11 files changed, 548 insertions(+), 216 deletions(-) create mode 100644 tests/ui/whisper-startup-electron.spec.ts create mode 100644 tests/unit/startupWhisperDownload.test.ts create mode 100644 tests/unit/whisperDownloadIpc.test.ts diff --git a/README.md b/README.md index 64aa879..1fa7dc2 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,15 @@ Marked issues are numbered `MX-001…`; items that come from narration alone are +## Local Transcription + +On desktop startup, MarkuprPlus downloads the multilingual Whisper **tiny** +model (~75 MB) in the background if no local model is installed. Existing +models are reused. Download progress and retryable errors appear under +**Settings > Advanced > Local Transcription**. Interrupted downloads resume on +the next startup; offline or failed downloads do not block opening the app. +Once downloaded, local transcription works without an internet connection. + ## Interface Language The desktop interface defaults to **English**, including when upgrading from an diff --git a/src/main/index.ts b/src/main/index.ts index 0e7e2d7..e1dbc6f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2035,6 +2035,13 @@ app.whenReady().then(async () => { setupIPC(); setupElectronTestHarnessIPC(); + if ((!electronTestHarnessAllowed() || process.env.MARKUPRX_E2E_DOWNLOAD_MODELS === '1') + && process.env.MARKUPRX_PACKAGE_SMOKE !== '1') { + void modelDownloadManager.ensureDefaultModel().catch((error: unknown) => { + console.warn('[Main] Automatic Whisper download failed. Retry in Settings > Advanced > Local Transcription:', error); + }); + } + // 11. Configure session controller event callbacks sessionController.setEventCallbacks({ onStateChange: handleSessionStateChange, @@ -2082,6 +2089,9 @@ app.on('window-all-closed', () => { // Handle before quit app.on('before-quit', () => { isQuitting = true; + for (const model of modelDownloadManager.getAvailableModels()) { + modelDownloadManager.cancelDownload(model.name); + } }); // Handle app quit diff --git a/src/main/ipc/windowHandlers.ts b/src/main/ipc/windowHandlers.ts index 253608c..472466c 100644 --- a/src/main/ipc/windowHandlers.ts +++ b/src/main/ipc/windowHandlers.ts @@ -7,7 +7,7 @@ import { ipcMain, app } from 'electron'; import { tierManager } from '../transcription/TierManager'; -import { modelDownloadManager } from '../transcription/ModelDownloadManager'; +import { DEFAULT_DOWNLOAD_MODEL, modelDownloadManager } from '../transcription/ModelDownloadManager'; import { whisperService } from '../transcription/WhisperService'; import type { WhisperModel } from '../transcription/types'; import { POPOVER_SIZES } from '../windows'; @@ -18,9 +18,25 @@ import { } from '../../shared/types'; import type { IpcContext } from './types'; -export function registerWindowHandlers(ctx: IpcContext): void { +export function registerWindowHandlers(ctx: Pick): void { const { getMainWindow, getPopover, getWindowsTaskbar } = ctx; + const sendDownloadEvent = (channel: string, payload: unknown): void => { + const window = getMainWindow(); + if (window && !window.isDestroyed()) window.webContents.send(channel, payload); + }; + // Startup downloads have no invoking renderer, so observe the manager globally. + modelDownloadManager.onProgress((progress) => { + sendDownloadEvent(IPC_CHANNELS.WHISPER_DOWNLOAD_PROGRESS, progress); + }); + modelDownloadManager.onComplete((result) => { + if (result.success) whisperService.setModelPath(result.path); + sendDownloadEvent(IPC_CHANNELS.WHISPER_DOWNLOAD_COMPLETE, { model: result.model, path: result.path }); + }); + modelDownloadManager.onError((error, model) => { + sendDownloadEvent(IPC_CHANNELS.WHISPER_DOWNLOAD_ERROR, { model, error: error.message }); + }); + // ------------------------------------------------------------------------- // App Version // ------------------------------------------------------------------------- @@ -216,8 +232,8 @@ export function registerWindowHandlers(ctx: IpcContext): void { } const defaultModel = hasAnyModel ? modelDownloadManager.getDefaultModel() : null; - const recommendedModel = 'tiny'; - const recommendedInfo = modelDownloadManager.getModelInfo('tiny'); + const recommendedModel = DEFAULT_DOWNLOAD_MODEL; + const recommendedInfo = modelDownloadManager.getModelInfo(recommendedModel); return { hasAnyModel, @@ -225,6 +241,7 @@ export function registerWindowHandlers(ctx: IpcContext): void { downloadedModels, recommendedModel, recommendedModelSizeMB: recommendedInfo.sizeMB, + downloadStatus: modelDownloadManager.getDownloadStatus(recommendedModel), }; }); @@ -251,43 +268,7 @@ export function registerWindowHandlers(ctx: IpcContext): void { return { success: false, error: 'Invalid model name' }; } try { - const unsubProgress = modelDownloadManager.onProgress((progress) => { - getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_PROGRESS, { - model: progress.model, - downloadedBytes: progress.downloadedBytes, - totalBytes: progress.totalBytes, - percent: progress.percent, - speedBps: progress.speedBps, - estimatedSecondsRemaining: progress.estimatedSecondsRemaining, - }); - }); - - const unsubComplete = modelDownloadManager.onComplete((result) => { - getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_COMPLETE, { - model: result.model, - path: result.path, - }); - unsubProgress(); - unsubComplete(); - unsubError(); - }); - - const unsubError = modelDownloadManager.onError((error, errorModel) => { - getMainWindow()?.webContents.send(IPC_CHANNELS.WHISPER_DOWNLOAD_ERROR, { - model: errorModel, - error: error.message, - }); - unsubProgress(); - unsubComplete(); - unsubError(); - }); - const result = await modelDownloadManager.downloadModel(model as WhisperModel); - - if (result.success) { - whisperService.setModelPath(result.path); - } - return { success: result.success }; } catch (error) { console.error('[Main] Failed to download Whisper model:', error); diff --git a/src/main/transcription/ModelDownloadManager.ts b/src/main/transcription/ModelDownloadManager.ts index 3bd06ae..489cb25 100644 --- a/src/main/transcription/ModelDownloadManager.ts +++ b/src/main/transcription/ModelDownloadManager.ts @@ -13,6 +13,8 @@ import { app } from 'electron'; import { createWriteStream, existsSync, statSync, unlinkSync, mkdirSync, renameSync } from 'fs'; import { join } from 'path'; import * as https from 'https'; +import type { IncomingMessage } from 'http'; +import type { WhisperModelDownloadStatus } from '../../shared/types'; import type { WhisperModel, ModelInfo, @@ -27,6 +29,7 @@ import type { // ============================================================================ const HUGGINGFACE_BASE_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main'; +export const DEFAULT_DOWNLOAD_MODEL: WhisperModel = 'tiny'; const MODEL_INFO: Record = { tiny: { @@ -83,6 +86,9 @@ const MODEL_INFO: Record = { export class ModelDownloadManager extends EventEmitter { private modelsDir: string; private activeDownloads: Map void; request?: ReturnType }> = new Map(); + private pendingDownloads = new Map>(); + private downloadProgress = new Map(); + private downloadErrors = new Map(); // Callbacks private progressCallbacks: ProgressCallback[] = []; @@ -138,16 +144,19 @@ export class ModelDownloadManager extends EventEmitter { * Check if a model is downloaded and valid */ isModelDownloaded(model: WhisperModel): boolean { - const path = this.getModelPath(model); + return this.isModelFileValid(model, this.getModelPath(model)); + } + + private isModelFileValid(model: WhisperModel, path: string): boolean { if (!existsSync(path)) { return false; } // Check file size matches expected (with 5% variance for compression differences) const stats = statSync(path); - const expectedSize = MODEL_INFO[model].sizeBytes; + const expectedSize = this.getModelInfo(model).sizeBytes; const variance = expectedSize * 0.05; - return Math.abs(stats.size - expectedSize) < variance; + return stats.isFile() && Math.abs(stats.size - expectedSize) < variance; } /** @@ -155,7 +164,7 @@ export class ModelDownloadManager extends EventEmitter { */ getDefaultModel(): WhisperModel { // Prefer medium, fall back to smaller models - const preference: WhisperModel[] = ['medium', 'small', 'base', 'tiny']; + const preference: WhisperModel[] = ['medium', 'small', 'base', 'tiny', 'large']; for (const model of preference) { if (this.isModelDownloaded(model)) { @@ -175,205 +184,161 @@ export class ModelDownloadManager extends EventEmitter { } /** - * Download a model with progress tracking + * Provision a small model without replacing an existing installation. */ - async downloadModel(model: WhisperModel): Promise { - const info = MODEL_INFO[model]; - const targetPath = this.getModelPath(model); - const tempPath = `${targetPath}.download`; + async ensureDefaultModel(): Promise { + if (this.hasAnyModel()) return null; + return this.downloadModel(DEFAULT_DOWNLOAD_MODEL); + } - // Check if already downloaded + getDownloadStatus(model: WhisperModel): WhisperModelDownloadStatus { + return { + model, + isDownloading: this.isDownloading(model), + percent: this.downloadProgress.get(model)?.percent ?? null, + error: this.downloadErrors.get(model) ?? null, + }; + } + + async downloadModel(model: WhisperModel): Promise { if (this.isModelDownloaded(model)) { this.log(`Model ${model} already downloaded`); - return { success: true, model, path: targetPath }; + return { success: true, model, path: this.getModelPath(model) }; } - - // Check if download already in progress - if (this.activeDownloads.has(model)) { - throw new Error(`Download already in progress for ${model}`); + const pending = this.pendingDownloads.get(model); + if (pending) return pending; + const download = this.performDownload(model); + this.pendingDownloads.set(model, download); + try { + return await download; + } finally { + this.pendingDownloads.delete(model); } + } + private performDownload(model: WhisperModel): Promise { + const info = this.getModelInfo(model); + const targetPath = this.getModelPath(model); + const tempPath = `${targetPath}.download`; + this.downloadErrors.delete(model); + this.downloadProgress.delete(model); this.log(`Starting download: ${model} (${info.sizeMB}MB)`); return new Promise((resolve, reject) => { - let downloadedBytes = 0; + let downloadedBytes = existsSync(tempPath) ? statSync(tempPath).size : 0; let lastProgressTime = Date.now(); - let lastDownloadedBytes = 0; - let aborted = false; - - // Create abort controller - const abort = (): void => { - aborted = true; - const download = this.activeDownloads.get(model); - if (download?.request) { - download.request.destroy(); - } - // Keep partial download for resume - this.activeDownloads.delete(model); - this.log(`Download cancelled: ${model}`); + let lastDownloadedBytes = downloadedBytes; + let settled = false; + let responseStream: IncomingMessage | undefined; + let writeStream: ReturnType | undefined; + + const fail = (error: Error): void => { + if (settled) return; + settled = true; + this.activeDownloads.get(model)?.request?.destroy(); + responseStream?.destroy(); + writeStream?.destroy(); + this.handleDownloadError(error, model, tempPath); + reject(error); }; + this.activeDownloads.set(model, { abort: () => fail(new Error(`Download cancelled: ${model}`)) }); - this.activeDownloads.set(model, { abort }); - - // Check for partial download (resume support) - if (existsSync(tempPath)) { - const stats = statSync(tempPath); - downloadedBytes = stats.size; - lastDownloadedBytes = downloadedBytes; - this.log(`Resuming download from ${Math.round(downloadedBytes / 1024 / 1024)}MB`); - } - - // Create write stream (append mode if resuming) - const writeStream = createWriteStream(tempPath, { - flags: downloadedBytes > 0 ? 'a' : 'w', - }); - - const handleResponse = (response: import('http').IncomingMessage, redirectCount: number = 0): void => { - if (aborted) { - return; - } - - // Handle redirects (Hugging Face uses redirects) - if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) { - if (redirectCount > 5) { - const error = new Error('Too many redirects'); - this.handleDownloadError(error, model, tempPath); - reject(error); + const request = (url: string, redirects = 0): void => { + if (settled) return; + const current = https.get(url, { + headers: downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {}, + }, (response) => { + if (settled) { + response.destroy(); return; } - - const redirectUrl = response.headers.location; - if (redirectUrl) { - this.log(`Following redirect to: ${redirectUrl.substring(0, 50)}...`); - const redirectRequest = https.get( - redirectUrl, - { - headers: downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {}, - }, - (redirectResponse) => { - handleResponse(redirectResponse, redirectCount + 1); - } - ); - - redirectRequest.on('error', (error) => { - this.handleDownloadError(error, model, tempPath); - reject(error); - }); - - // Store request for abort - const download = this.activeDownloads.get(model); - if (download) { - download.request = redirectRequest; + responseStream = response; + response.on('error', fail); + response.on('aborted', () => fail(new Error('Whisper model download interrupted'))); + if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) { + const location = response.headers.location; + if (!location || redirects >= 5) { + fail(new Error('Invalid or excessive model download redirects')); + return; + } + response.resume(); + try { + request(new URL(location, url).href, redirects + 1); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); } return; } - } - - // Handle partial content (resume) or full content - if (response.statusCode !== 200 && response.statusCode !== 206) { - const error = new Error(`Download failed: HTTP ${response.statusCode}`); - this.handleDownloadError(error, model, tempPath); - reject(error); - return; - } - - const contentLength = parseInt(response.headers['content-length'] || '0', 10); - const totalBytes = downloadedBytes + contentLength; - - response.on('data', (chunk: Buffer) => { - if (aborted) { - response.destroy(); + if (response.statusCode !== 200 && response.statusCode !== 206) { + if (response.statusCode === 416 && downloadedBytes > 0) { + downloadedBytes = 0; + response.resume(); + request(info.url); + return; + } + fail(new Error(`Download failed: HTTP ${response.statusCode}`)); return; } - downloadedBytes += chunk.length; - - // Calculate progress - const now = Date.now(); - const timeDelta = (now - lastProgressTime) / 1000; - - if (timeDelta >= 0.1) { - // Update every 100ms - const bytesDelta = downloadedBytes - lastDownloadedBytes; - const speedBps = timeDelta > 0 ? bytesDelta / timeDelta : 0; - const remainingBytes = totalBytes - downloadedBytes; - const estimatedSecondsRemaining = speedBps > 0 ? remainingBytes / speedBps : 0; - + // A server may ignore Range and send the entire file again. + if (response.statusCode === 200) downloadedBytes = 0; + lastDownloadedBytes = downloadedBytes; + const totalBytes = downloadedBytes + Number(response.headers['content-length'] || 0); + writeStream = createWriteStream(tempPath, { flags: downloadedBytes > 0 ? 'a' : 'w' }); + writeStream.on('error', fail); + response.on('data', (chunk: Buffer) => { + if (settled) return; + downloadedBytes += chunk.length; + const now = Date.now(); + const timeDelta = (now - lastProgressTime) / 1000; + if (timeDelta < 0.1) return; + const speedBps = (downloadedBytes - lastDownloadedBytes) / timeDelta; const progress: DownloadProgress = { model, downloadedBytes, totalBytes, - percent: totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0, + percent: totalBytes > 0 ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0, speedBps: Math.round(speedBps), - estimatedSecondsRemaining: Math.round(estimatedSecondsRemaining), + estimatedSecondsRemaining: speedBps > 0 ? Math.max(0, (totalBytes - downloadedBytes) / speedBps) : 0, }; - - this.progressCallbacks.forEach((cb) => cb(progress)); + this.downloadProgress.set(model, progress); + this.progressCallbacks.forEach((callback) => callback(progress)); this.emit('progress', progress); - lastProgressTime = now; lastDownloadedBytes = downloadedBytes; - } - }); - - response.pipe(writeStream); - - writeStream.on('finish', () => { - if (aborted) { - return; - } - - this.activeDownloads.delete(model); - - // Rename temp file to final - try { - renameSync(tempPath, targetPath); - } catch (renameError) { - this.handleDownloadError(renameError as Error, model, tempPath); - reject(renameError); - return; - } - - // Verify download - if (this.isModelDownloaded(model)) { + }); + + writeStream.on('finish', () => { + if (settled) return; + try { + if (!this.isModelFileValid(model, tempPath)) { + fail(new Error('Downloaded file size mismatch - download may be corrupted')); + return; + } + renameSync(tempPath, targetPath); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + return; + } + settled = true; + this.activeDownloads.delete(model); const result: DownloadResult = { success: true, model, path: targetPath }; this.completeCallbacks.forEach((cb) => cb(result)); this.emit('complete', result); this.log(`Download complete: ${model}`); resolve(result); - } else { - const error = new Error('Downloaded file size mismatch - download may be corrupted'); - this.handleDownloadError(error, model, targetPath); - reject(error); - } - }); - - writeStream.on('error', (error) => { - this.handleDownloadError(error, model, tempPath); - reject(error); + }); + response.pipe(writeStream); }); + current.on('error', fail); + current.setTimeout(30_000, () => fail(new Error('Whisper model download timed out'))); + const active = this.activeDownloads.get(model); + if (active) active.request = current; }; - - // Make initial HTTP request - const request = https.get( - info.url, - { - headers: downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {}, - }, - (response) => { - handleResponse(response); - } - ); - - request.on('error', (error) => { - this.handleDownloadError(error, model, tempPath); - reject(error); - }); - - // Store request for abort - const download = this.activeDownloads.get(model); - if (download) { - download.request = request; + try { + request(info.url); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); } }); } @@ -469,10 +434,11 @@ export class ModelDownloadManager extends EventEmitter { private handleDownloadError(error: Error, model: WhisperModel, _tempPath: string): void { this.activeDownloads.delete(model); + this.downloadErrors.set(model, error.message); // Keep partial download for resume (don't delete tempPath) this.errorCallbacks.forEach((cb) => cb(error, model)); - this.emit('error', error, model); + if (this.listenerCount('error') > 0) this.emit('error', error, model); this.logError(`Download failed: ${model}`, error); } diff --git a/src/renderer/components/settings/AdvancedTab.tsx b/src/renderer/components/settings/AdvancedTab.tsx index 58751c7..9959455 100644 --- a/src/renderer/components/settings/AdvancedTab.tsx +++ b/src/renderer/components/settings/AdvancedTab.tsx @@ -76,6 +76,9 @@ export const AdvancedTab: React.FC<{ onResetSection, }) => { const { colors } = useTheme(); + const downloadStatus = whisperModelStatus?.downloadStatus; + const downloading = isRepairingLocalTranscription || Boolean(downloadStatus?.isDownloading); + const downloadError = localTranscriptionError || downloadStatus?.error; return (
{currentDistribution() === 'mas' && ( @@ -110,20 +113,24 @@ export const AdvancedTab: React.FC<{ {whisperModelStatus?.hasAnyModel ? 'Local transcription ready' - : whisperModelStatus + : downloading + ? 'Downloading local transcription model' + : whisperModelStatus ? 'Local transcription needs repair' : 'Checking local transcription…'} {whisperModelStatus?.hasAnyModel ? `Managed model: ${whisperModelStatus.defaultModel ?? whisperModelStatus.downloadedModels[0]}` - : whisperModelStatus + : downloading + ? `Downloading ${downloadStatus?.model ?? 'tiny'}${downloadStatus?.percent != null ? ` (${downloadStatus.percent}%)` : ''}. You can keep using the app.` + : whisperModelStatus ? `Download the managed ${whisperModelStatus.recommendedModel} model for automatic local transcription.` : 'Checking the managed local transcription model.'} - {localTranscriptionError && ( + {downloadError && ( - {localTranscriptionError} + {downloadError} )}
@@ -132,9 +139,9 @@ export const AdvancedTab: React.FC<{ type="button" style={styles.secondaryButton} onClick={onRepairLocalTranscription} - disabled={isRepairingLocalTranscription} + disabled={downloading} > - {isRepairingLocalTranscription ? 'Downloading…' : 'Repair local transcription'} + {downloading ? 'Downloading…' : 'Repair local transcription'} )}
diff --git a/src/renderer/components/settings/useSettingsPanel.ts b/src/renderer/components/settings/useSettingsPanel.ts index 7f0d785..50d5c6c 100644 --- a/src/renderer/components/settings/useSettingsPanel.ts +++ b/src/renderer/components/settings/useSettingsPanel.ts @@ -136,6 +136,39 @@ export function useSettingsPanel(isOpen: boolean, onClose: () => void, initialTa setSettings((previous) => ({ ...previous, uiLanguage })); }), []); + useEffect(() => { + if (!isOpen) return; + let active = true; + const refresh = () => { + void window.markuprx.whisper.checkModel().then((status) => { + if (active) setWhisperModelStatus(status); + }).catch((error: unknown) => { + if (active) setLocalTranscriptionError( + error instanceof Error ? error.message : 'Unable to check the local transcription model.', + ); + }); + }; + const unsubscribeProgress = window.markuprx.whisper.onDownloadProgress((progress) => { + setLocalTranscriptionError(null); + setWhisperModelStatus((previous) => previous && ({ + ...previous, + downloadStatus: { model: progress.model, isDownloading: true, percent: progress.percent, error: null }, + })); + }); + const unsubscribeComplete = window.markuprx.whisper.onDownloadComplete(refresh); + const unsubscribeError = window.markuprx.whisper.onDownloadError(({ error }) => { + setLocalTranscriptionError(error); + refresh(); + }); + refresh(); + return () => { + active = false; + unsubscribeProgress(); + unsubscribeComplete(); + unsubscribeError(); + }; + }, [isOpen]); + useEffect(() => { if (!isOpen) return; @@ -145,11 +178,10 @@ export function useSettingsPanel(isOpen: boolean, onClose: () => void, initialTa const loadedSettings = { ...DEFAULT_SETTINGS, ...allSettings }; setSettings(loadedSettings); - const [devices, providerStatuses, { hasOpenAiKey, hasAnthropicKey }, localModelStatus, bridgeStatus] = await Promise.all([ + const [devices, providerStatuses, { hasOpenAiKey, hasAnthropicKey }, bridgeStatus] = await Promise.all([ window.markuprx.audio.getDevices(), refreshAnalysisProviders(false), getApiKeyPresence(), - window.markuprx.whisper.checkModel().catch(() => null), IS_MAS_DISTRIBUTION ? window.markuprx.cliBridge.status().catch((): CliBridgeConnectionStatus => ({ state: 'offline', @@ -159,7 +191,6 @@ export function useSettingsPanel(isOpen: boolean, onClose: () => void, initialTa : Promise.resolve(null), ]); setAudioDevices(devices); - setWhisperModelStatus(localModelStatus); setCliBridgeStatus(bridgeStatus); if (hasOpenAiKey) { setOpenAiApiKey((prev) => ({ ...prev, value: MASKED_API_KEY_PLACEHOLDER, valid: true })); diff --git a/src/renderer/traditionalChinese.ts b/src/renderer/traditionalChinese.ts index 8ce2dea..7c241fb 100644 --- a/src/renderer/traditionalChinese.ts +++ b/src/renderer/traditionalChinese.ts @@ -6,6 +6,7 @@ import type { UiTranslationCatalog } from './i18n/catalogs'; const EXACT_TRANSLATIONS: Record = { 'Interface language': '介面語言', + 'Downloading local transcription model': '正在下載本機轉錄模型', 'English by default. Changes apply immediately.': '預設為英文。變更會立即套用。', 'Recording Active': '正在錄製', 'Latest Report Path': '最新報告位置', @@ -531,6 +532,7 @@ const EXACT_TRANSLATIONS: Record = { }; const FRAGMENT_TRANSLATIONS: Array<[string, string]> = [ + ['You can keep using the app.', '您可以繼續使用應用程式。'], ['Welcome to ', '歡迎使用 '], [' and intelligent screenshots.', ' 與智慧螢幕截圖。'], ['MarkuprPlus needs microphone access to transcribe your voice narration as you', 'MarkuprPlus 需要麥克風權限,才能在你'], diff --git a/src/shared/types.ts b/src/shared/types.ts index eafbdf3..64b338b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -879,12 +879,20 @@ export interface WhisperModelInfoPayload { /** * Whisper model check result */ +export interface WhisperModelDownloadStatus { + model: string; + isDownloading: boolean; + percent: number | null; + error: string | null; +} + export interface WhisperModelCheckResult { hasAnyModel: boolean; defaultModel: string | null; downloadedModels: string[]; recommendedModel: string; recommendedModelSizeMB: number; + downloadStatus?: WhisperModelDownloadStatus; } /** diff --git a/tests/ui/whisper-startup-electron.spec.ts b/tests/ui/whisper-startup-electron.spec.ts new file mode 100644 index 0000000..bf2fcfb --- /dev/null +++ b/tests/ui/whisper-startup-electron.spec.ts @@ -0,0 +1,85 @@ +import { _electron as electron, expect, test } from '@playwright/test'; +import { resolve } from 'node:path'; +import { stat } from 'node:fs/promises'; +import { createElectronHarnessEnvironment } from '../fixtures/electronHarness'; + +const root = resolve(import.meta.dirname, '../..'); + +test('shows background model progress and download failures in Settings', async () => { + const harness = await createElectronHarnessEnvironment(); + const application = await electron.launch({ args: [root], env: harness.env }); + try { + const page = await application.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await application.evaluate(({ ipcMain, BrowserWindow }) => { + ipcMain.removeHandler('markuprx:whisper:check-model'); + ipcMain.handle('markuprx:whisper:check-model', () => ({ + hasAnyModel: false, defaultModel: null, downloadedModels: [], + recommendedModel: 'tiny', recommendedModelSizeMB: 75, + downloadStatus: { model: 'tiny', isDownloading: true, percent: 12, error: null }, + })); + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('markuprx:show-settings'); + window.show(); + } + }); + await page.locator('#markuprx-settings-tab-advanced').click(); + await expect(page.getByText('Downloading tiny (12%). You can keep using the app.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Downloading…', exact: true })).toBeDisabled(); + await application.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].webContents.send('markuprx:whisper:download-progress', { + model: 'tiny', percent: 42, + }); + }); + await expect(page.getByText('Downloading tiny (42%). You can keep using the app.')).toBeVisible(); + await application.evaluate(({ ipcMain, BrowserWindow }) => { + ipcMain.removeHandler('markuprx:whisper:check-model'); + ipcMain.handle('markuprx:whisper:check-model', () => ({ + hasAnyModel: false, defaultModel: null, downloadedModels: [], + recommendedModel: 'tiny', recommendedModelSizeMB: 75, + downloadStatus: { model: 'tiny', isDownloading: false, percent: 42, error: 'Network unavailable' }, + })); + BrowserWindow.getAllWindows()[0].webContents.send('markuprx:whisper:download-error', { + model: 'tiny', error: 'Network unavailable', + }); + }); + await expect(page.getByText('Network unavailable', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Repair local transcription', exact: true })).toBeEnabled(); + } finally { + await application.close(); + await harness.cleanup(); + } +}); + +test('downloads and reuses a real Whisper model automatically on desktop startup', async () => { + test.skip(process.env.MARKUPRPLUS_TEST_WHISPER_DOWNLOAD !== '1', 'Opt-in 75 MB public model download'); + test.setTimeout(180_000); + const harness = await createElectronHarnessEnvironment(); + const env = { ...harness.env, MARKUPRX_E2E_DOWNLOAD_MODELS: '1' }; + let application = await electron.launch({ args: [root], env }); + try { + let page = await application.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await expect.poll( + async () => { + const status = await page.evaluate(() => window.markuprx.whisper.checkModel()); + if (status.downloadStatus?.error) throw new Error(status.downloadStatus.error); + return status.hasAnyModel; + }, + { timeout: 150_000, intervals: [250, 500, 1000] }, + ).toBe(true); + const modelPath = resolve(harness.userDataDir, 'whisper-models/ggml-tiny.bin'); + const downloaded = await stat(modelPath); + expect(downloaded.size).toBeGreaterThan(70_000_000); + await application.close(); + application = await electron.launch({ args: [root], env }); + page = await application.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + await expect.poll(() => page.evaluate(() => window.markuprx.whisper.checkModel())) + .toMatchObject({ hasAnyModel: true, defaultModel: 'tiny', downloadStatus: { isDownloading: false } }); + expect((await stat(modelPath)).mtimeMs).toBe(downloaded.mtimeMs); + } finally { + await application.close(); + await harness.cleanup(); + } +}); diff --git a/tests/unit/startupWhisperDownload.test.ts b/tests/unit/startupWhisperDownload.test.ts new file mode 100644 index 0000000..624a811 --- /dev/null +++ b/tests/unit/startupWhisperDownload.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from 'node:events'; +import { Socket } from 'node:net'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { app } from 'electron'; +import { IncomingMessage, type RequestOptions } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelDownloadManager } from '../../src/main/transcription/ModelDownloadManager'; + +const network = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock('https', () => network); + +class Request extends EventEmitter { + destroy = vi.fn(); + setTimeout = vi.fn(); +} + +class Response extends IncomingMessage { + constructor(statusCode: number, headers: Record) { + super(new Socket()); + this.statusCode = statusCode; + this.headers = headers; + } +} + +describe('startup Whisper model download', () => { + let directory: string; + let manager: ModelDownloadManager; + let requests: Array<{ request: Request; options: RequestOptions; respond: (response: IncomingMessage) => void }>; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'markuprplus-model-test-')); + vi.spyOn(app, 'getPath').mockReturnValue(directory); + manager = new ModelDownloadManager(); + const getInfo = manager.getModelInfo.bind(manager); + vi.spyOn(manager, 'getModelInfo').mockImplementation((model) => ({ + ...getInfo(model), sizeBytes: 4, + })); + requests = []; + network.get.mockImplementation((_url: string, options: RequestOptions, respond: (response: IncomingMessage) => void) => { + const request = new Request(); + requests.push({ request, options, respond }); + return request; + }); + }); + + afterEach(() => { + manager.cancelDownload('tiny'); + vi.restoreAllMocks(); + network.get.mockReset(); + rmSync(directory, { recursive: true, force: true }); + }); + + function respond(index = 0, status = 200, body = 'abcd') { + const response = new Response(status, { 'content-length': String(body.length) }); + requests[index].respond(response); + response.push(body); + response.complete = true; + response.push(null); + return response; + } + + it('downloads tiny once when no model exists and makes it available', async () => { + const result = manager.ensureDefaultModel(); + expect(manager.isDownloading('tiny')).toBe(true); + expect(manager.getDownloadStatus('tiny')).toMatchObject({ isDownloading: true, error: null }); + respond(); + await expect(result).resolves.toMatchObject({ success: true, model: 'tiny' }); + expect(readFileSync(manager.getModelPath('tiny'), 'utf8')).toBe('abcd'); + expect(manager.hasAnyModel()).toBe(true); + await expect(manager.ensureDefaultModel()).resolves.toBeNull(); + expect(requests).toHaveLength(1); + }); + + it.each(['tiny', 'base', 'small', 'medium', 'large'] as const)( + 'does not replace an existing %s model', + async (model) => { + writeFileSync(manager.getModelPath(model), 'abcd'); + await expect(manager.ensureDefaultModel()).resolves.toBeNull(); + expect(requests).toHaveLength(0); + }, + ); + + it('shares an in-flight transfer with a manual download request', async () => { + const startup = manager.ensureDefaultModel(); + const manual = manager.downloadModel('tiny'); + const repeatedStartup = manager.ensureDefaultModel(); + expect(requests).toHaveLength(1); + respond(); + const results = await Promise.all([startup, manual, repeatedStartup]); + expect(results.every((result) => result?.success)).toBe(true); + }); + + it('reports offline errors without an unhandled EventEmitter error and allows retry', async () => { + const first = manager.ensureDefaultModel(); + const rejected = expect(first).rejects.toThrow('offline'); + expect(() => requests[0].request.emit('error', new Error('offline'))).not.toThrow(); + await rejected; + expect(manager.getDownloadStatus('tiny')).toMatchObject({ isDownloading: false, error: 'offline' }); + const retry = manager.ensureDefaultModel(); + respond(1); + await expect(retry).resolves.toMatchObject({ success: true }); + expect(manager.getDownloadStatus('tiny').error).toBeNull(); + }); + + it('restarts rather than appending when the server ignores a resume Range', async () => { + writeFileSync(`${manager.getModelPath('tiny')}.download`, 'ab'); + const result = manager.ensureDefaultModel(); + expect(requests[0].options.headers).toEqual({ Range: 'bytes=2-' }); + respond(); + await expect(result).resolves.toMatchObject({ success: true }); + expect(readFileSync(manager.getModelPath('tiny'), 'utf8')).toBe('abcd'); + }); + + it('continues an interrupted transfer when the server honors Range', async () => { + writeFileSync(`${manager.getModelPath('tiny')}.download`, 'ab'); + const result = manager.ensureDefaultModel(); + respond(0, 206, 'cd'); + await expect(result).resolves.toMatchObject({ success: true }); + expect(readFileSync(manager.getModelPath('tiny'), 'utf8')).toBe('abcd'); + }); + + it('restarts a stale partial download when the server rejects its Range', async () => { + writeFileSync(`${manager.getModelPath('tiny')}.download`, 'abcd'); + const result = manager.ensureDefaultModel().catch((error: unknown) => error); + respond(0, 416, ''); + expect(requests).toHaveLength(2); + expect(requests[1].options.headers).toEqual({}); + respond(1); + await expect(result).resolves.toMatchObject({ success: true }); + }); + + it('settles cancellation and keeps the partial model for the next startup', async () => { + writeFileSync(`${manager.getModelPath('tiny')}.download`, 'ab'); + const result = manager.ensureDefaultModel(); + const rejected = expect(result).rejects.toThrow(/cancelled/i); + manager.cancelDownload('tiny'); + await rejected; + expect(requests[0].request.destroy).toHaveBeenCalled(); + expect(manager.isDownloading('tiny')).toBe(false); + expect(readFileSync(`${manager.getModelPath('tiny')}.download`, 'utf8')).toBe('ab'); + }); + + it('rejects a disconnected response without publishing an incomplete model', async () => { + const result = manager.ensureDefaultModel(); + const rejected = expect(result).rejects.toThrow(/interrupted/i); + const response = new Response(200, { 'content-length': '4' }); + requests[0].respond(response); + response.emit('aborted'); + await rejected; + expect(manager.hasAnyModel()).toBe(false); + }); + + it('bounds stalled requests with a timeout', async () => { + const result = manager.ensureDefaultModel(); + const rejected = expect(result).rejects.toThrow(/timed out/i); + const [milliseconds, onTimeout] = requests[0].request.setTimeout.mock.calls[0]; + expect(milliseconds).toBe(30_000); + onTimeout(); + await rejected; + expect(manager.isDownloading('tiny')).toBe(false); + }); +}); diff --git a/tests/unit/whisperDownloadIpc.test.ts b/tests/unit/whisperDownloadIpc.test.ts new file mode 100644 index 0000000..3868618 --- /dev/null +++ b/tests/unit/whisperDownloadIpc.test.ts @@ -0,0 +1,69 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IPC_CHANNELS } from '../../src/shared/types'; +import { registerWindowHandlers } from '../../src/main/ipc/windowHandlers'; + +const { models, whisper } = vi.hoisted(() => ({ + models: { + onProgress: vi.fn(), onComplete: vi.fn(), onError: vi.fn(), + hasAnyModel: vi.fn(() => false), + isModelDownloaded: vi.fn(() => false), + getDefaultModel: vi.fn(() => 'tiny'), + getModelInfo: vi.fn(() => ({ sizeMB: 75 })), + getDownloadStatus: vi.fn(() => ({ model: 'tiny', isDownloading: true, percent: 42, error: null })), + downloadModel: vi.fn(), + }, + whisper: { setModelPath: vi.fn() }, +})); +vi.mock('../../src/main/transcription/ModelDownloadManager', () => ({ + DEFAULT_DOWNLOAD_MODEL: 'tiny', modelDownloadManager: models, +})); +vi.mock('../../src/main/transcription/WhisperService', () => ({ whisperService: whisper })); +vi.mock('../../src/main/transcription/TierManager', () => ({ tierManager: {} })); + +describe('Whisper download IPC notifications', () => { + let window: BrowserWindow; + beforeEach(() => { + vi.clearAllMocks(); + window = new BrowserWindow(); + registerWindowHandlers({ + getMainWindow: () => window, + getPopover: () => null, + getWindowsTaskbar: () => null, + }); + }); + + it('activates models and forwards completion without a renderer-initiated download', () => { + models.onComplete.mock.calls[0][0]({ success: true, model: 'tiny', path: '/models/ggml-tiny.bin' }); + expect(whisper.setModelPath).toHaveBeenCalledWith('/models/ggml-tiny.bin'); + expect(window.webContents.send).toHaveBeenCalledWith(IPC_CHANNELS.WHISPER_DOWNLOAD_COMPLETE, { + model: 'tiny', path: '/models/ggml-tiny.bin', + }); + expect(models.downloadModel).not.toHaveBeenCalled(); + }); + + it('forwards background progress and errors to the current window', () => { + const progress = { model: 'tiny', percent: 42 }; + models.onProgress.mock.calls[0][0](progress); + models.onError.mock.calls[0][0](new Error('offline'), 'tiny'); + expect(window.webContents.send).toHaveBeenCalledWith(IPC_CHANNELS.WHISPER_DOWNLOAD_PROGRESS, progress); + expect(window.webContents.send).toHaveBeenCalledWith(IPC_CHANNELS.WHISPER_DOWNLOAD_ERROR, { + model: 'tiny', error: 'offline', + }); + }); + + it('includes in-progress status for settings opened after the download started', () => { + const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => channel === IPC_CHANNELS.WHISPER_CHECK_MODEL)?.[1]; + if (!handler) throw new Error('Missing Whisper model check handler'); + expect(Reflect.apply(handler, undefined, [])).toMatchObject({ + recommendedModel: 'tiny', + downloadStatus: { model: 'tiny', isDownloading: true, percent: 42, error: null }, + }); + }); + + it('does not send events to a destroyed window during shutdown', () => { + vi.mocked(window.isDestroyed).mockReturnValue(true); + models.onError.mock.calls[0][0](new Error('cancelled'), 'tiny'); + expect(window.webContents.send).not.toHaveBeenCalled(); + }); +});