diff --git a/electron/bundledModel.test.js b/electron/bundledModel.test.js new file mode 100644 index 00000000..2f922fb8 --- /dev/null +++ b/electron/bundledModel.test.js @@ -0,0 +1,46 @@ +// Dictation has to work the instant the app opens: press fn, talk, done. The model is 190MB and +// lived only as a runtime download, so a fresh install (or anyone offline, or on slow wifi) pressed +// fn into silence while it fetched. It is now staged into Resources/whisper at build time. +// +// resolveModelFile already prefers a bundled model over userData, so these pin the two halves that +// can silently drift: the build must stage it, and the resolver must look for the one we staged. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const SH = path.join(__dirname, 'scripts', 'build-whisper.sh'); +const MODELS = require('./voice/whisperModels.js'); + +test('the build stages the DEFAULT model, the same one the resolver looks for', () => { + const sh = fs.readFileSync(SH, 'utf-8'); + const wanted = MODELS.modelById(MODELS.DEFAULT_MODEL_ID).file; + assert.ok(sh.includes(wanted), + `build-whisper.sh must stage ${wanted}; the resolver checks resourceDir for exactly that name`); +}); + +test('a corrupt model is refused at build time rather than shipped', () => { + const sh = fs.readFileSync(SH, 'utf-8'); + const expected = MODELS.modelById(MODELS.DEFAULT_MODEL_ID).sha256; + assert.ok(sh.includes(expected), 'the staged checksum must match the catalog'); + assert.ok(sh.includes('checksum mismatch') && sh.includes('exit 1'), + 'a truncated model ships silently and dies at the first press, so it must fail the build'); +}); + +test('a build without the model warns instead of passing quietly', () => { + const sh = fs.readFileSync(SH, 'utf-8'); + assert.ok(sh.includes('WARNING') && sh.includes('WITHOUT a bundled model'), + 'silently dropping the model reintroduces the first-press wait with nothing saying so'); +}); + +test('the bundled model is NOT swept into app.asar', () => { + // It belongs in extraResources. Inside the asar it would cost every user the 181MB twice over, + // which is the regression removed on 2026-08-25. + const pkg = require('./package.json'); + assert.ok(pkg.build.files.includes('!whisper') && pkg.build.files.includes('!whisper/**')); + // Platform-scoped: the entry lives under build.mac.extraResources, not build.extraResources. + const extra = JSON.stringify(pkg.build.mac.extraResources); + assert.ok(extra.includes('build-staging/whisper'), 'it reaches Resources via extraResources only'); + assert.ok(extra.includes('"to":"whisper"') || extra.includes('"to": "whisper"'), + 'and lands at Resources/whisper, which is what voiceResourceDir() resolves'); +}); diff --git a/electron/scripts/build-whisper.sh b/electron/scripts/build-whisper.sh index 4a2a7c1b..7217f463 100755 --- a/electron/scripts/build-whisper.sh +++ b/electron/scripts/build-whisper.sh @@ -59,3 +59,25 @@ file "$OUT/whisper-server" if otool -L "$OUT/whisper-server" | grep -qE "/opt/homebrew|/usr/local"; then echo "[whisper] ERROR: binary links non-system libraries"; otool -L "$OUT/whisper-server"; exit 1 fi + +# Bundle the default dictation model so fn works the instant the app opens, with no network at all. +# resolveModelFile (voice/whisperModels.js) already looks for DEFAULT_MODEL_ID here in resourceDir +# before anything in userData, so staging the file is the whole change. Costs ~181MB in the DMG; +# the alternative is a user pressing fn into silence while 190MB downloads behind them. +MODEL_FILE="ggml-small.en-q5_1.bin" +MODEL_SHA="bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30" +MODEL_SRC="$(cd "$(dirname "$0")/.." && pwd)/whisper/$MODEL_FILE" +if [[ -f "$MODEL_SRC" ]]; then + # Verify BEFORE copying: a truncated model ships silently and dies at the first press. + GOT="$(shasum -a 256 "$MODEL_SRC" | cut -d' ' -f1)" + if [[ "$GOT" != "$MODEL_SHA" ]]; then + echo "[whisper] ERROR: $MODEL_FILE checksum mismatch (got $GOT); refusing to bundle a corrupt model" + exit 1 + fi + cp "$MODEL_SRC" "$OUT/$MODEL_FILE" + echo "[whisper] bundled model -> $OUT/$MODEL_FILE ($(du -h "$OUT/$MODEL_FILE" | cut -f1))" +else + # Not fatal: the app still prefetches at boot. But say it, because a build without the model + # silently reintroduces the first-press wait this was added to remove. + echo "[whisper] WARNING: $MODEL_SRC missing; shipping WITHOUT a bundled model (first fn press will wait on a 190MB download)" +fi