diff --git a/README.md b/README.md index 1016ad9..5180502 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,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 8e87a65..ef5fe2e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2043,6 +2043,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, @@ -2090,6 +2097,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 9ceb241..bb2293e 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 cf5ccfb..d763a27 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: { @@ -92,6 +95,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[] = []; @@ -147,16 +153,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; } /** @@ -184,205 +193,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))); } }); } @@ -478,10 +443,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 7a17017..09a4d3a 100644 --- a/src/renderer/components/settings/AdvancedTab.tsx +++ b/src/renderer/components/settings/AdvancedTab.tsx @@ -104,6 +104,9 @@ export const AdvancedTab: React.FC<{ } }; + const downloadStatus = whisperModelStatus?.downloadStatus; + const downloading = isRepairingLocalTranscription || Boolean(downloadStatus?.isDownloading); + const downloadError = localTranscriptionError || downloadStatus?.error; return (
{currentDistribution() === 'mas' && ( @@ -138,20 +141,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} )}
@@ -160,9 +167,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 72b5332..721f723 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': '最新報告位置', @@ -537,6 +538,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 545e088..7af22fa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -884,12 +884,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..d4a8d6c --- /dev/null +++ b/tests/ui/whisper-startup-electron.spec.ts @@ -0,0 +1,90 @@ +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(); + const vocabularyHints = page.getByRole('textbox', { name: 'Whisper vocabulary hints', exact: true }); + await vocabularyHints.fill('card, potion, HUD'); + await vocabularyHints.blur(); + await expect.poll(() => page.evaluate(() => window.markuprx.settings.get('localWhisperPrompt'))) + .toBe('card, potion, HUD'); + 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..b2bf672 --- /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-turbo', '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(); + }); +});