Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ src-tauri/resources/server-archive/*
!src-tauri/resources/server-archive/placeholder.txt
src-tauri/resources/node/*
!src-tauri/resources/node/placeholder.txt
src-tauri/resources/whisper/*
!src-tauri/resources/whisper/placeholder.txt

# Notarized release artifacts
release/
Expand Down
4 changes: 4 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ echo " found: $APP_PATH"
# release instead of shipping that.
require_file "$APP_PATH/Contents/Resources/resources/node/bin/node"
echo " bundled Node runtime present"
WHISPER_CLI="$APP_PATH/Contents/Resources/resources/whisper/whisper-cli"
require_file "$WHISPER_CLI"
"$WHISPER_CLI" --version >/dev/null
echo " bundled Whisper runtime present"

echo "==> Signing every native binary inside the bundle"
# Apple deprecated --deep; sign inner native binaries explicitly so each one
Expand Down
2 changes: 2 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,7 @@ export const SUITES = {
"src/app/api/profile-route.test.ts",
"src/lib/voice/registry.test.ts",
"src/lib/voice/speech-models.test.ts",
"src/lib/voice/sidecar-whisper.test.ts",
"src/lib/voice/local-loop.test.ts",
"src/lib/voice/speech-loop.test.ts",
"src/lib/voice/native-stt.test.ts",
Expand All @@ -1100,6 +1101,7 @@ export const SUITES = {
"src/lib/voice/use-dictation.test.ts",
"src/app/api/voice/session/route.test.ts",
"src/app/api/voice/engines/route.test.ts",
"src/app/api/voice/engines/whisper/route.test.ts",
"src/app/api/voice/preview/route.test.ts",
"src/app/api/voice/local/chat/route.test.ts",
"src/app/api/voice/elevenlabs/tts/route.test.ts",
Expand Down
12 changes: 8 additions & 4 deletions scripts/sidecar-bundle-deps.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ for (const forbiddenRoot of [
]) {
assert.match(closureSource, new RegExp(forbiddenRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `runtime verifier must exclude ${forbiddenRoot}`);
}
assert.match(closureSource, /fileCount: 5_693/, "runtime closure must retain combined cross-platform headroom");
assert.match(closureSource, /fileCount: 5_706/, "runtime closure must retain combined cross-platform headroom");
assert.match(closureSource, /unpackedBytes: 200 \* 1024 \* 1024 - 1/, "runtime closure must stay strictly below 200 MiB expanded");

// App-size: runtime bundles must drop test/dev packages and metadata that are
Expand Down Expand Up @@ -135,13 +135,17 @@ assert.doesNotMatch(
// Tauri assembles the app.
assert.deepEqual(
windowsConfig.bundle.resources,
["resources/server-archive/**/*", "resources/node/**/*"],
"Windows resources must replace the expanded sidecar with its bounded archive",
["resources/server-archive/**/*", "resources/node/**/*", "resources/whisper/**/*"],
"Windows resources must replace the expanded sidecar with its bounded archive while retaining bundled runtimes",
);
assert.ok(
baseConfig.bundle.resources.includes("resources/server/**/*"),
"non-Windows bundles must retain the expanded tree for nested native signing",
);
assert.ok(
baseConfig.bundle.resources.includes("resources/whisper/**/*"),
"desktop bundles must retain the local Whisper runtime",
);
assert.match(src, /WINDOWS_ARCHIVE/, "Windows sidecar must be emitted as a tar.zst archive");
assert.match(src, /sidecar-archive-manifest\.mjs/, "archive generation must emit its integrity and size manifest");
assert.match(src, /\.server\.tar\.zst\.\$\$\.tmp/, "archive generation must use a same-directory staging path");
Expand Down Expand Up @@ -171,7 +175,7 @@ assert.match(
);
assert.match(
rustArchiveSource,
/const MAX_FILE_COUNT: u64 = 5_693;/,
/const MAX_FILE_COUNT: u64 = 5_706;/,
"Windows archive extractor must accept the shared runtime file-count budget",
);
assert.match(manifestSource, /isSymbolicLink\(\)/, "archive input must reject symlinks");
Expand Down
3 changes: 3 additions & 0 deletions scripts/sidecar-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ copy_node_shared_runtime "$NODE_BIN" "$BUNDLED_NODE_DIR"
"$BUNDLED_NODE_DIR/bin/$NODE_NAME" -e "process.exit(0)" >/dev/null
printf "generated at release build time\n" > "$BUNDLED_NODE_DIR/placeholder.txt"

echo "==> staging bundled Whisper runtime"
bash "$ROOT/scripts/whisper-runtime-bundle.sh"

# Next.js + pnpm leaves a node_modules full of pnpm-style symlinks
# (.pnpm/* paths) that don't survive the copy into the .app bundle. Recreate
# production deps from the committed pnpm lockfile in a staging dir, then copy
Expand Down
5 changes: 4 additions & 1 deletion scripts/sidecar-runtime-closure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ export const SIDECAR_RUNTIME_BUDGETS = Object.freeze({
// ([id]/files/[key]) traces two more chunks; the PR merge tree measured
// 5,683 on Windows. Retain the ten-file buffer without relaxing the byte
// ceiling.
fileCount: 5_693,
// 2026-07-24 (local Whisper STT): the local-origin transcription endpoint
// and its sidecar runner trace at 5,694 on Linux and 5,696 on Windows.
// Retain the established ten-file cross-platform buffer.
fileCount: 5_706,
unpackedBytes: 200 * 1024 * 1024 - 1,
});

Expand Down
4 changes: 2 additions & 2 deletions scripts/sidecar-runtime-closure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,10 @@ try {

await assembleSidecarRuntime(projectRoot, standaloneRoot, dependencyRoot, destination);
const metrics = await verifySidecarRuntime(destination);
assert.ok(metrics.fileCount <= 5_693);
assert.ok(metrics.fileCount <= 5_706);
assert.ok(metrics.unpackedBytes < 200 * 1024 * 1024);
assert.deepEqual(SIDECAR_RUNTIME_BUDGETS, {
fileCount: 5_693,
fileCount: 5_706,
unpackedBytes: 200 * 1024 * 1024 - 1,
});

Expand Down
28 changes: 27 additions & 1 deletion scripts/sidecar-runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const bundledNode = path.join(
"bin",
process.platform === "win32" ? "node.exe" : "node",
);
const bundledWhisper = path.join(
root,
"src-tauri",
"resources",
"whisper",
process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli",
);
const token = "sidecar-runtime-smoke-token";

function reservePort() {
Expand Down Expand Up @@ -104,6 +111,7 @@ function launchSidecar({ sidecarServer, sidecarRoot, covenHome, port }) {
HOSTNAME: "127.0.0.1",
PORT: String(port),
COVEN_CAVE_BUNDLE: "1",
COVEN_WHISPER_CPP_BIN: bundledWhisper,
COVEN_CAVE_AUTH_TOKEN: token,
COVEN_HOME: covenHome,
NEXT_TELEMETRY_DISABLED: "1",
Expand Down Expand Up @@ -148,7 +156,7 @@ async function main() {
assert.match(manifest.payloadSha256, /^[a-f0-9]{64}$/);
assert.match(manifest.treeSha256, /^[a-f0-9]{64}$/);
assert.match(manifest.archiveSha256, /^[a-f0-9]{64}$/);
assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_693);
assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_706);
assert.ok(manifest.archiveBytes > 0 && manifest.archiveBytes <= 80 * 1024 * 1024);
assert.ok(manifest.unpackedBytes > 0 && manifest.unpackedBytes < 200 * 1024 * 1024);
extractedSidecarRoot = await mkdtemp(path.join(os.tmpdir(), "coven-cave-sidecar-archive-"));
Expand Down Expand Up @@ -190,6 +198,24 @@ async function main() {
}
await access(sidecarServer);
await access(bundledNode);
await access(bundledWhisper);
if (process.platform === "win32") {
for (const runtimeDll of ["MSVCP140.dll", "VCRUNTIME140.dll", "VCRUNTIME140_1.dll", "VCOMP140.dll"]) {
await access(path.join(path.dirname(bundledWhisper), runtimeDll));
}
}

const whisperVersion = spawnSync(bundledWhisper, ["--version"], {
encoding: "utf8",
env: process.platform === "linux"
? { ...process.env, LD_LIBRARY_PATH: path.dirname(bundledWhisper) }
: process.env,
});
assert.equal(
whisperVersion.status,
0,
`packaged Whisper CLI must launch from resources: ${whisperVersion.stderr || whisperVersion.error}`,
);

const nativeModules = spawnSync(
bundledNode,
Expand Down
140 changes: 140 additions & 0 deletions scripts/whisper-runtime-bundle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Stage a pinned whisper.cpp runtime beside the desktop sidecar. The output is
# a release resource, not part of the Next.js server archive: Windows keeps
# its DLLs adjacent to whisper-cli.exe and macOS signs the nested executable.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/src-tauri/resources/whisper"
VERSION="v1.9.1"
RELEASE_URL="https://github.com/ggml-org/whisper.cpp/releases/download/$VERSION"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/coven-whisper-runtime.XXXXXX")"

cleanup() {
rm -rf "$WORK"
}
trap cleanup EXIT

sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}

download_verified() {
local url="$1"
local expected="$2"
local output="$3"
curl --fail --location --proto '=https' --tlsv1.2 --retry 3 --retry-delay 2 "$url" -o "$output"
local actual
actual="$(sha256_file "$output")"
if [ "$actual" != "$expected" ]; then
echo "ERROR: Whisper runtime checksum mismatch for $url" >&2
echo " expected $expected, got $actual" >&2
exit 1
fi
}

stage_linux() {
local archive="$WORK/whisper.tar.gz" archive_name archive_sha source
case "$(uname -m)" in
x86_64|amd64)
archive_name="whisper-bin-ubuntu-x64.tar.gz"
archive_sha="f3bf3b4369a99b54665b0f19b88483b30de27f25963b0414235dea03198515c5"
;;
aarch64|arm64)
archive_name="whisper-bin-ubuntu-arm64.tar.gz"
archive_sha="e0b66cd551ff6f2a28fabe3c6e89691eea037bb76833493abb9a71ca788994b3"
;;
*)
echo "ERROR: no bundled Whisper runtime for Linux architecture $(uname -m)" >&2
exit 1
;;
esac
download_verified \
"$RELEASE_URL/$archive_name" \
"$archive_sha" \
"$archive"
tar -xzf "$archive" -C "$WORK"
source="$WORK/${archive_name%.tar.gz}"
cp -L "$source/whisper-cli" "$DEST/whisper-cli"
# Preserve both the versioned ELF files and their SONAME links (for example
# libwhisper.so.1 -> libwhisper.so.1.9.1). The binary requests the SONAME,
# not necessarily the filename selected by the archive extraction order.
find "$source" -maxdepth 1 \( -type f -o -type l \) \( -name 'libwhisper.so*' -o -name 'libggml*.so*' \) -exec cp -P {} "$DEST/" \;
test -L "$DEST/libwhisper.so.1"
chmod 755 "$DEST/whisper-cli"
}

stage_windows() {
local archive="$WORK/whisper.zip"
download_verified \
"$RELEASE_URL/whisper-bin-x64.zip" \
"7d8be46ecd31828e1eb7a2ecdd0d6b314feafd82163038ab6092594b0a063539" \
"$archive"
unzip -q "$archive" -d "$WORK/unpacked"
local source="$WORK/unpacked/Release"
cp "$source/whisper-cli.exe" "$DEST/whisper-cli.exe"
cp "$source/whisper.dll" "$DEST/whisper.dll"
find "$source" -maxdepth 1 -type f -name 'ggml*.dll' -exec cp {} "$DEST/" \;

# whisper.cpp's official Windows build is dynamically linked to the MSVC
# CRT/OpenMP DLLs. Ship the redistributable app-locally so a fresh Windows
# install does not need a separate Visual C++ Redistributable install.
local runtime_dir="" candidate dll complete
local -a msvc_dlls=(MSVCP140.dll VCRUNTIME140.dll VCRUNTIME140_1.dll VCOMP140.dll)
for candidate in \
"${VCToolsRedistDir:-}/x64/Microsoft.VC143.CRT" \
"${WINDIR:-C:/Windows}/System32"; do
[ -d "$candidate" ] || continue
complete=1
for dll in "${msvc_dlls[@]}"; do
[ -f "$candidate/$dll" ] || { complete=0; break; }
done
if [ "$complete" = "1" ]; then runtime_dir="$candidate"; break; fi
done
if [ -z "$runtime_dir" ]; then
echo "ERROR: could not find the x64 Microsoft Visual C++ runtime for bundled Whisper" >&2
exit 1
fi
for dll in "${msvc_dlls[@]}"; do cp "$runtime_dir/$dll" "$DEST/$dll"; done
}

stage_macos() {
# whisper.cpp does not publish a macOS CLI archive. Build the exact immutable
# release commit on the matching release host; release.sh then signs it.
local source="$WORK/whisper.cpp"
command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required to build bundled Whisper on macOS" >&2; exit 1; }
git init -q "$source"
git -C "$source" remote add origin https://github.com/ggml-org/whisper.cpp.git
git -C "$source" fetch -q --depth 1 origin f049fff95a089aa9969deb009cdd4892b3e74916
git -C "$source" checkout -q --detach FETCH_HEAD
test "$(git -C "$source" rev-parse HEAD)" = "f049fff95a089aa9969deb009cdd4892b3e74916"
# Keep copied dylibs discoverable after the temporary build tree disappears.
# The release bundle co-locates them with whisper-cli, so @loader_path is
# both relocatable and compatible with the later nested-code-signing pass.
cmake -S "$source" -B "$source/build" \
-DWHISPER_BUILD_TESTS=OFF \
-DWHISPER_BUILD_EXAMPLES=ON \
-DCMAKE_BUILD_RPATH='@loader_path' \
-DCMAKE_INSTALL_RPATH='@loader_path' \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON
cmake --build "$source/build" --target whisper-cli --parallel 2
cp "$source/build/bin/whisper-cli" "$DEST/whisper-cli"
find "$source/build/bin" -maxdepth 1 -type f -name '*.dylib' -exec cp {} "$DEST/" \;
chmod 755 "$DEST/whisper-cli"
}

rm -rf "$DEST"
mkdir -p "$DEST"

case "$(uname -s)" in
Linux) stage_linux ;;
Darwin) stage_macos ;;
MINGW*|MSYS*|CYGWIN*) stage_windows ;;
*) echo "ERROR: no bundled Whisper runtime for $(uname -s)" >&2; exit 1 ;;
esac

echo "==> bundled whisper.cpp $VERSION ($(find "$DEST" -maxdepth 1 -type f | wc -l | tr -d ' ') files)"
35 changes: 32 additions & 3 deletions src-tauri/release-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ async function readNativeHost(...modules) {
)).join("\n");
}

test("release bundle includes and prefers a bundled Node runtime", async () => {
const [tauriConfig, windowsConfig, bundleScript, launcher] = await Promise.all([
test("release bundle includes and prefers bundled Node and Whisper runtimes", async () => {
const [tauriConfig, windowsConfig, bundleScript, whisperBundleScript, launcher] = await Promise.all([
readFile(new URL("./tauri.conf.json", import.meta.url), "utf8"),
readFile(new URL("./tauri.windows.conf.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/sidecar-bundle.sh", import.meta.url), "utf8"),
readFile(new URL("../scripts/whisper-runtime-bundle.sh", import.meta.url), "utf8"),
readNativeHost("sidecar_discovery.rs", "sidecar_startup.rs"),
]);

Expand All @@ -135,6 +136,8 @@ test("release bundle includes and prefers a bundled Node runtime", async () => {
/"resources\/node\/\*\*\/\*"/,
"Tauri resources must include the bundled Node runtime",
);
assert.match(tauriConfig, /"resources\/whisper\/\*\*\/\*"/, "Tauri resources must include Whisper runtime files");
assert.match(windowsConfig, /"resources\/whisper\/\*\*\/\*"/, "Windows bundles must include Whisper runtime files");
assert.match(
windowsConfig,
/"resources\/server-archive\/\*\*\/\*"/,
Expand All @@ -160,6 +163,20 @@ test("release bundle includes and prefers a bundled Node runtime", async () => {
/command -v node/,
"sidecar bundle script must copy the release runner's Node binary",
);
assert.match(bundleScript, /whisper-runtime-bundle\.sh/, "sidecar bundle script must stage Whisper before packaging");
assert.match(whisperBundleScript, /whisper-bin-x64\.zip/, "Windows must stage a pinned Whisper CLI archive");
assert.match(whisperBundleScript, /MSVCP140\.dll.*VCRUNTIME140\.dll.*VCRUNTIME140_1\.dll.*VCOMP140\.dll/, "Windows must ship Whisper's app-local MSVC runtime");
assert.match(whisperBundleScript, /whisper-bin-ubuntu-x64\.tar\.gz/, "Linux must stage a pinned Whisper CLI archive");
assert.match(
whisperBundleScript,
/aarch64\|arm64\)[\s\S]*?whisper-bin-ubuntu-arm64\.tar\.gz[\s\S]*?e0b66cd551ff6f2a28fabe3c6e89691eea037bb76833493abb9a71ca788994b3/,
"Linux ARM64 must stage and verify its matching Whisper CLI archive",
);
assert.match(whisperBundleScript, /f049fff95a089aa9969deb009cdd4892b3e74916/, "macOS must build the pinned Whisper release commit");
assert.match(whisperBundleScript, /CMAKE_INSTALL_RPATH='@loader_path'/, "macOS Whisper must resolve copied dylibs relative to its executable");
assert.doesNotMatch(whisperBundleScript, /install_name_tool -add_rpath/, "macOS Whisper must not add a duplicate CMake-provided rpath");
assert.match(whisperBundleScript, /cp -P/, "Linux Whisper staging must preserve SONAME links");
assert.match(whisperBundleScript, /checksum mismatch/, "Whisper artifact downloads must be hash-verified");
assert.match(
launcher,
/fn find_node\(resource_dir: &Path\)/,
Expand All @@ -170,6 +187,9 @@ test("release bundle includes and prefers a bundled Node runtime", async () => {
/resources[\s\S]*node[\s\S]*bin[\s\S]*node/,
"launcher must know the bundled Node resource path",
);
assert.match(launcher, /fn find_bundled_whisper_cli\(resource_dir: &Path\)/, "launcher must resolve Whisper relative to app resources");
assert.match(launcher, /COVEN_WHISPER_CPP_BIN/, "launcher must provide the absolute bundled Whisper path to the sidecar");
assert.match(launcher, /LD_LIBRARY_PATH/, "Linux sidecars must load Whisper's bundled shared libraries");
assert.match(
launcher,
/sidecar_archive::prepare_sidecar_runtime\(app, &resource_dir\)/,
Expand All @@ -184,6 +204,7 @@ test("clean release runners have resource glob placeholders", async () => {
access(new URL("./resources/server/placeholder.txt", import.meta.url)),
access(new URL("./resources/server-archive/placeholder.txt", import.meta.url)),
access(new URL("./resources/node/placeholder.txt", import.meta.url)),
access(new URL("./resources/whisper/placeholder.txt", import.meta.url)),
]);

assert.match(
Expand All @@ -201,6 +222,14 @@ test("clean release runners have resource glob placeholders", async () => {
/!src-tauri\/resources\/node\/placeholder\.txt/,
"node placeholder must be tracked so resources/node/**/* matches in clean CI",
);
const releaseScript = await readFile(new URL("../scripts/release.sh", import.meta.url), "utf8");
assert.match(releaseScript, /WHISPER_CLI=.*resources\/whisper\/whisper-cli/, "macOS release must resolve bundled Whisper before signing");
assert.match(releaseScript, /"\$WHISPER_CLI" --version/, "macOS release must smoke-test the copied Whisper runtime");
assert.match(
gitignore,
/!src-tauri\/resources\/whisper\/placeholder\.txt/,
"Whisper placeholder must be tracked so resources/whisper/**/* matches in clean CI",
);
});

test("native updater cleanup stops the sidecar before Windows exits", async () => {
Expand Down Expand Up @@ -385,7 +414,7 @@ test("mobile startup remains available after native-host extraction", async () =

assert.match(nativeHost, /\nmod tauri_setup;/);
assert.doesNotMatch(nativeHost, /#\[cfg\(desktop\)\]\s*\nmod tauri_setup;/);
assert.match(setup, /#\[cfg_attr\(mobile, tauri::mobile_entry_point\)\]\npub fn run\(\)/);
assert.match(setup, /#\[cfg_attr\(mobile, tauri::mobile_entry_point\)\]\r?\npub fn run\(\)/);
});

test("Windows close watchdog helper follows extracted lifecycle tests", async () => {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/resources/whisper/placeholder.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
generated at release build time
Loading
Loading