diff --git a/docs/berd-call-updater-testing.md b/docs/berd-call-updater-testing.md new file mode 100644 index 000000000..8aa9e6d9c --- /dev/null +++ b/docs/berd-call-updater-testing.md @@ -0,0 +1,51 @@ +# Bundled Berd Call updater rehearsal + +This macOS scenario tests the real packaged updater without starting a voice call or changing the production Berd application. It cold-launches an isolated app through `berd://update-check`, serves a signed archive over local HTTPS, holds the same shared lock as a call, and verifies replacement after releasing that lock. + +The test requires pnpm, Python 3, OpenSSL with `req -addext`, and the normal Berd build prerequisites. Port 14443 must be available. The `--trust-localhost` option explicitly authorizes temporarily trusting a generated certificate in the login keychain. macOS may ask for confirmation. Normal completion, failures, and handled interruption remove the certificate and its trust. Do not forcibly kill the test with SIGKILL. No developer signing identity or production updater key is required. + +## Build the isolated source + +From the checkout, run normal dependency and sidecar preparation (`just setup`), then: + +```sh +VITE_UPDATER_ENABLED=true VITE_AUTH_GATE=0 pnpm tauri build --features berdctl,app-test-driver --bundles app --config tests/app-e2e/bundled-updater.tauri.json +``` + +Use the absolute `Berd.app` path printed by the build. The overlay gives the app a separate E2E identifier and registers the native updater plugin. The test rejects a production app identifier. The fixture's configuration key is not a secret: each test generates its own signing key and replaces the disposable copy's release catalog. + +## Run the signed replacement scenario + +```sh +node scripts/test-bundled-updater.mjs /absolute/path/from/build/Berd.app --trust-localhost +``` + +The script creates source and target bundles, signing keys, the signed compatibility descriptor, HTTPS feed, run directory, and driver token. It copies the source under a unique user Applications directory, disables legacy-data migration and keyring access through E2E mode, and leaves the original source and production app unchanged. + +Expected output: + +```text +SIGNED_ARCHIVE_DOWNLOADED_WITH_INSTALL_BLOCKED +SIGNED_REPLACEMENT_VERIFIED manifests=1 archives=1 evidence=... app=... +TEST_CERTIFICATE_TRUST_REMOVED +``` + +The target is a synthetic `99.0.0` fixture containing an added proof file, not a new release binary. The test verifies that the signed target payload replaces the disposable app, its bundle signature remains valid, and its bundled CLI matches the target archive. The call-equivalent lock avoids microphone use; it tests the same cross-process installation barrier but does not retest speech or call startup. Evidence and disposable bundles are retained at the printed paths for inspection. The app and its backend are stopped on completion. + +If the machine crashes or the process is forcibly killed, remove test trust with `security remove-trusted-cert /cert.pem`. Read its fingerprint using `openssl x509 -in /cert.pem -noout -fingerprint -sha1`, then remove that exact certificate using `security delete-certificate -Z ~/Library/Keychains/login.keychain-db`. Never delete other certificates. + +## Development command installation and restoration + +To exercise installation and restoration without replacing an existing developer installation, use disposable command directories. Substitute the built bundle's absolute CLI path in the first two commands: + +```sh +install_root=$(mktemp -d) +mkdir "$install_root/bin" +ln -s /absolute/path/from/build/Berd.app/Contents/MacOS/berd-call "$install_root/bin/berd-call" +BERD_CALL_DEV_BINDIR="$install_root/bin" BERD_CALL_DEV_LIBEXECDIR="$install_root/libexec" bash scripts/install-berd-call-dev.sh install /absolute/path/from/build/Berd.app/Contents/MacOS/berd-call +"$install_root/bin/berd-call" --version +BERD_CALL_DEV_BINDIR="$install_root/bin" BERD_CALL_DEV_LIBEXECDIR="$install_root/libexec" bash scripts/install-berd-call-dev.sh uninstall +readlink "$install_root/bin/berd-call" +``` + +The command prints its version after installation, and the final link points back to the exact original bundle CLI. `just install-berd-call-dev` runs this same installer after building the development binary; `just uninstall-berd-call-dev` runs its uninstall action. An executable fixture scenario in `scripts/install-berd-call-dev.test.mjs` additionally covers reinstallation and refusal to replace unrelated commands. diff --git a/justfile b/justfile index 27c54a6b1..484356cc9 100644 --- a/justfile +++ b/justfile @@ -86,6 +86,17 @@ setup: _setup-dev-deps just _install-lefthook GOOSE_DEV_MODE=required ./scripts/ensure-local-goose.sh +# Build and install a stable user-local Berd Call development command. +[unix] +install-berd-call-dev: + just _tauri-cargo-unix build -p berd-call --bin berd-call + bash ./scripts/install-berd-call-dev.sh install "$(bash ./scripts/resolve-tauri-cargo-target-dir.sh)/debug/berd-call" + +# Remove the development command, restoring the installed app CLI if present. +[unix] +uninstall-berd-call-dev: + bash ./scripts/install-berd-call-dev.sh uninstall + # ── Build & Check ──────────────────────────────────────────── # Run the frontend non-test checks: design-system guardrails, berdctl contract freshness, formatting, lint, i18n, and TypeScript. @@ -630,7 +641,7 @@ stage-sidecar: [unix] _stage-sidecar-unix: - TAURI_CARGO_TARGET_DIR="$(bash ./scripts/resolve-tauri-cargo-target-dir.sh)" && GOOSE_BUILD_PROFILE=debug ./scripts/prepare-goose-sidecar.sh && CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh && ./scripts/prepare-catch-sidecar.sh + TAURI_CARGO_TARGET_DIR="$(bash ./scripts/resolve-tauri-cargo-target-dir.sh)" && GOOSE_BUILD_PROFILE=debug ./scripts/prepare-goose-sidecar.sh && BERD_CALL_BUNDLE=0 CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh && ./scripts/prepare-catch-sidecar.sh [windows] _stage-sidecar-windows: diff --git a/scripts/install-berd-call-dev.sh b/scripts/install-berd-call-dev.sh new file mode 100644 index 000000000..f96e1692d --- /dev/null +++ b/scripts/install-berd-call-dev.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -euo pipefail + +action="${1:-}" +source_binary="${2:-}" +bin_dir="${BERD_CALL_DEV_BINDIR:-$HOME/.local/bin}" +libexec_dir="${BERD_CALL_DEV_LIBEXECDIR:-$HOME/.local/libexec}" +dev_binary="$libexec_dir/berd-call-dev" +command_link="$bin_dir/berd-call" +restore_link="$libexec_dir/berd-call-release" + +case "$action" in + install) + released_target="" + [[ -x "$source_binary" ]] || { echo "Missing built berd-call binary: $source_binary" >&2; exit 1; } + if [[ -e "$command_link" || -L "$command_link" ]]; then + [[ -L "$command_link" ]] || { + echo "Refusing to replace existing $command_link" >&2 + exit 1 + } + existing_target="$(readlink "$command_link")" + if [[ "$existing_target" != "$dev_binary" ]]; then + case "$existing_target" in + /*/Berd.app/Contents/MacOS/berd-call) + mkdir -p "$libexec_dir" + [[ ! -e "$restore_link" && ! -L "$restore_link" ]] || { + echo "Existing restoration link at $restore_link; refusing to overwrite it" >&2 + exit 1 + } + released_target="$existing_target" + ;; + *) echo "Refusing to replace existing $command_link" >&2; exit 1 ;; + esac + fi + fi + mkdir -p "$bin_dir" "$libexec_dir" + staged_binary="$(mktemp "$libexec_dir/.berd-call-dev.XXXXXXXX")" + trap 'rm -f "$staged_binary"' EXIT + install -m 755 "$source_binary" "$staged_binary" + mv -f "$staged_binary" "$dev_binary" + trap - EXIT + if [[ -n "$released_target" ]]; then + ln -s "$released_target" "$restore_link" + fi + if [[ -L "$command_link" && "$(readlink "$command_link")" != "$dev_binary" ]]; then + rm "$command_link" + fi + [[ -L "$command_link" ]] || ln -s "$dev_binary" "$command_link" + echo "Installed $command_link -> $dev_binary" + ;; + uninstall) + [[ -L "$command_link" && "$(readlink "$command_link")" == "$dev_binary" ]] || { + echo "No Berd Call development link found at $command_link" >&2 + exit 1 + } + rm "$command_link" + if [[ -L "$restore_link" ]]; then + ln -s "$(readlink "$restore_link")" "$command_link" + rm "$restore_link" + echo "Restored $command_link to its original Berd app" + elif [[ -x /Applications/Berd.app/Contents/MacOS/berd-call ]]; then + ln -s /Applications/Berd.app/Contents/MacOS/berd-call "$command_link" + echo "Restored $command_link to the installed Berd app" + else + echo "Removed development link; no released Berd Call CLI is installed" + fi + rm -f "$dev_binary" + ;; + *) + echo "Usage: $0 install BUILT_BINARY | uninstall" >&2 + exit 2 + ;; +esac diff --git a/scripts/install-berd-call-dev.test.mjs b/scripts/install-berd-call-dev.test.mjs new file mode 100644 index 000000000..c58c2ca9c --- /dev/null +++ b/scripts/install-berd-call-dev.test.mjs @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + symlinkSync, + readlinkSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +test("dev installation restores the exact released bundle link and refuses unrelated commands", () => { + const root = mkdtempSync(join(tmpdir(), "berd-call-install-test-")); + try { + const bin = join(root, "bin"); + const libexec = join(root, "libexec"); + mkdirSync(bin); + const command = join(bin, "berd-call"); + const released = join( + root, + "Custom Location", + "Berd.app", + "Contents", + "MacOS", + "berd-call", + ); + mkdirSync(join(root, "Custom Location", "Berd.app", "Contents", "MacOS"), { + recursive: true, + }); + symlinkSync("/usr/bin/true", released); + symlinkSync(released, command); + const run = (...args) => + spawnSync("bash", ["scripts/install-berd-call-dev.sh", ...args], { + encoding: "utf8", + env: { + ...process.env, + BERD_CALL_DEV_BINDIR: bin, + BERD_CALL_DEV_LIBEXECDIR: libexec, + }, + }); + let result = run("install", "/usr/bin/true"); + assert.equal(result.status, 0, result.stderr); + assert.equal(readlinkSync(command), join(libexec, "berd-call-dev")); + result = run("install", "/usr/bin/true"); + assert.equal(result.status, 0, result.stderr); + result = run("uninstall"); + assert.equal(result.status, 0, result.stderr); + assert.equal(readlinkSync(command), released); + rmSync(command); + symlinkSync("/usr/bin/true", command); + result = run("install", "/usr/bin/true"); + assert.notEqual(result.status, 0); + assert.equal(readlinkSync(command), "/usr/bin/true"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index 0f9e4838e..0ed182741 100755 --- a/scripts/prepare-berdctl-sidecar.sh +++ b/scripts/prepare-berdctl-sidecar.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and stage the berdctl and berd-monitor CLIs for Tauri externalBin bundling. +# Build and stage Berd's CLIs for Tauri externalBin bundling. # # Tauri expects external binaries to be present at build time with the target # triple appended to the configured stem. For config @@ -13,14 +13,17 @@ usage() { cat <<'USAGE' Usage: scripts/prepare-berdctl-sidecar.sh [target-triple] -Builds the berdctl and berd-monitor workspace crates in release mode and -copies both binaries into src-tauri/binaries with the target triple suffix +Builds the berdctl and berd-monitor workspace crates in release mode, plus +berd-call for macOS targets, and copies their binaries with the triple suffix required by Tauri. The triple defaults to the rustc host. Pass it explicitly (or set BERDCTL_TRIPLE) when the Tauri build itself uses an explicit --target, so the staged name matches the triple Tauri resolves (e.g. aarch64-apple-darwin in release CI). + +Set BERD_CALL_BUNDLE=0 only for the dev profile, which has no externalBin, +to skip linking the standalone berd-call binary during routine app startup. USAGE } @@ -44,6 +47,11 @@ else exit 1 fi fi +BUNDLE_BERD_CALL=0 +if [[ "$TRIPLE" == *apple-darwin && "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then + BUNDLE_BERD_CALL=1 + CARGO_ARGS+=(-p berd-call) +fi (cd src-tauri && cargo "${CARGO_ARGS[@]}") @@ -58,37 +66,29 @@ if [[ -z "$TARGET_DIR" ]]; then TARGET_DIR="${CARGO_TARGET_DIR:-src-tauri/target}" fi -# Cargo nests output under the triple only when --target is passed. -if [[ -n "$EXPLICIT_TRIPLE" ]]; then - BUILT="$TARGET_DIR/$TRIPLE/release/berdctl" -else - BUILT="$TARGET_DIR/release/berdctl" -fi - -if [[ ! -x "$BUILT" ]]; then - echo "Built berdctl binary not found at: $BUILT" >&2 - exit 1 -fi - OUT_DIR="src-tauri/binaries" -OUT="$OUT_DIR/berdctl-$TRIPLE" mkdir -p "$OUT_DIR" -cp "$BUILT" "$OUT" -chmod +x "$OUT" -echo "Staged berdctl sidecar: $OUT" -if [[ -n "$EXPLICIT_TRIPLE" ]]; then - MONITOR_BUILT="$TARGET_DIR/$TRIPLE/release/berd-monitor" -else - MONITOR_BUILT="$TARGET_DIR/release/berd-monitor" -fi +stage_cli() { + local name="$1" built out + # Cargo nests output under the triple only when --target is passed. + if [[ -n "$EXPLICIT_TRIPLE" ]]; then + built="$TARGET_DIR/$TRIPLE/release/$name" + else + built="$TARGET_DIR/release/$name" + fi + if [[ ! -x "$built" ]]; then + echo "Built $name binary not found at: $built" >&2 + exit 1 + fi + out="$OUT_DIR/$name-$TRIPLE" + cp "$built" "$out" + chmod +x "$out" + echo "Staged $name sidecar: $out" +} -if [[ ! -x "$MONITOR_BUILT" ]]; then - echo "Built berd-monitor binary not found at: $MONITOR_BUILT" >&2 - exit 1 +stage_cli berdctl +if [[ "$BUNDLE_BERD_CALL" == "1" ]]; then + stage_cli berd-call fi - -MONITOR_OUT="$OUT_DIR/berd-monitor-$TRIPLE" -cp "$MONITOR_BUILT" "$MONITOR_OUT" -chmod +x "$MONITOR_OUT" -echo "Staged berd-monitor sidecar: $MONITOR_OUT" +stage_cli berd-monitor diff --git a/scripts/release/package-signed-updater.sh b/scripts/release/package-signed-updater.sh index 0fa32ace7..28878aa1d 100755 --- a/scripts/release/package-signed-updater.sh +++ b/scripts/release/package-signed-updater.sh @@ -93,7 +93,9 @@ ARCHIVE="$OUTPUT_DIR/$ARCHIVE_NAME" rm -f "$ARCHIVE" "$ARCHIVE.sig" "$ARCHIVE.sha256" # Keep Berd.app at the archive root; that is the bundle shape tauri-plugin-updater # atomically installs on macOS. -tar -C "$WORK_DIR" -czf "$ARCHIVE" "${APP_BUNDLE_NAME}.app" +# macOS tar otherwise injects AppleDouble `._*` entries for extended +# attributes; Tauri's raw tar extractor cannot unpack the root-level entry. +COPYFILE_DISABLE=1 tar -C "$WORK_DIR" -czf "$ARCHIVE" "${APP_BUNDLE_NAME}.app" ARCHIVE_LIST="$WORK_DIR/archive-contents.txt" tar -tzf "$ARCHIVE" > "$ARCHIVE_LIST" grep -Fxq "${APP_BUNDLE_NAME}.app/" "$ARCHIVE_LIST" diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index c776793f4..023c3f16f 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; +import { gunzipSync } from "node:zlib"; import { parse as parseYaml } from "yaml"; const repo = resolve(import.meta.dirname, "../../.."); @@ -562,6 +563,27 @@ describe("development Block-feature resources", () => { }); describe("build-macos Block-service feature seam", () => { + it("bundles the same berd-call binary as the macOS app update", async () => { + const config = JSON.parse( + await readFile(join(repo, "src-tauri/tauri.macos.conf.json"), "utf8"), + ); + const stage = await readFile( + join(repo, "scripts/prepare-berdctl-sidecar.sh"), + "utf8", + ); + const release = await readFile( + join(repo, "scripts/release/build-macos.sh"), + "utf8", + ); + expect(config.bundle.externalBin).toContain("binaries/berd-call"); + expect(stage).toContain("CARGO_ARGS+=(-p berd-call)"); + expect(stage).toContain("stage_cli berd-call"); + expect(stage).toContain('out="$OUT_DIR/$name-$TRIPLE"'); + expect(release).toContain( + './scripts/prepare-berdctl-sidecar.sh "$TARGET_TRIPLE"', + ); + }); + it("defaults every Block-service family off and maps each opt-in to packaging", async () => { const script = await readFile( join(repo, "scripts/release/build-macos.sh"), @@ -1087,7 +1109,7 @@ printf 'fake-signature\r\n' > "$payload.sig" }); describe("package-signed-updater", () => { - it("uses the version/platform-qualified filename and keeps Berd.app at archive root", async () => { + it("keeps Berd.app at the archive root without AppleDouble entries", async () => { const dir = await tempDir(); const app = join(dir, "Berd.app"); const zip = join(dir, "Berd.app.zip"); @@ -1141,6 +1163,26 @@ set -euo pipefail const listing = run("tar", ["-tzf", archive]); expect(listing.status, listing.stderr).toBe(0); expect(listing.stdout.split("\n")[0]).toBe("Berd.app/"); + // macOS tar hides AppleDouble entries when listing, but Tauri's Rust + // extractor sees them and cannot unpack the root-level ._Berd.app. + const tar = gunzipSync(await readFile(archive)); + const entries = []; + for (let offset = 0; offset + 512 <= tar.length; ) { + const header = tar.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString().replace(/\0.*$/, ""); + if (!name) break; + entries.push(name); + const size = Number.parseInt( + header.subarray(124, 136).toString().replace(/\0.*$/, "").trim() || "0", + 8, + ); + offset += 512 + Math.ceil(size / 512) * 512; + } + expect( + entries.filter((name) => + name.split("/").some((part) => part.startsWith("._")), + ), + ).toEqual([]); expect(await readFile(`${archive}.sig`, "utf8")).toBe("fake-signature"); expect(await readFile(`${archive}.sha256`, "utf8")).toContain( "Berd_1.2.3_darwin-aarch64.app.tar.gz", diff --git a/scripts/test-bundled-updater.mjs b/scripts/test-bundled-updater.mjs new file mode 100644 index 000000000..7b933db29 --- /dev/null +++ b/scripts/test-bundled-updater.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node +// Exercise signed replacement and the call lock using an isolated macOS bundle. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import https from "node:https"; +import { randomBytes } from "node:crypto"; +import { spawn, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const [source, consent] = process.argv.slice(2); +if ( + process.platform !== "darwin" || + !source || + consent !== "--trust-localhost" +) { + throw new Error( + "Usage (macOS): node scripts/test-bundled-updater.mjs /absolute/isolated/Berd.app --trust-localhost. Temporarily trusts a generated localhost certificate and removes it on exit.", + ); +} +const execute = (bin, args, env = {}) => + execFileSync(bin, args, { + cwd: repo, + env: { ...process.env, ...env }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +const identifier = execute("/usr/libexec/PlistBuddy", [ + "-c", + "Print CFBundleIdentifier", + path.join(source, "Contents/Info.plist"), +]); +if ( + !path.isAbsolute(source) || + !/^xyz\.block\.berd\.e2e\.[a-zA-Z0-9-]+$/.test(identifier) +) { + throw new Error( + "Source must be an absolute path to an isolated E2E bundle, never the production app", + ); +} +const runId = identifier.slice("xyz.block.berd.e2e.".length); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "berd-updater-test.")); +const runRoot = path.join(root, runId); +fs.mkdirSync(runRoot); +const applications = path.join(os.homedir(), "Applications"); +fs.mkdirSync(applications, { recursive: true }); +const parent = fs.mkdtempSync(path.join(applications, "Berd-updater-test.")); +const app = path.join(parent, "Berd.app"); +const targetParent = path.join(root, "target"); +fs.mkdirSync(targetParent); +const target = path.join(targetParent, "Berd.app"); +const version = "99.0.0"; +const platform = process.arch === "arm64" ? "darwin-aarch64" : "darwin-x86_64"; +const archive = path.join(root, `Berd_${version}_${platform}.app.tar.gz`); +const cert = path.join(root, "cert.pem"); +let server, + lock, + pid, + trusted = false, + fingerprint; +const delay = (ms = 200) => new Promise((resolve) => setTimeout(resolve, ms)); +let interrupted = false; +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + interrupted = true; + }); +} +async function until(predicate, label) { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + if (interrupted) throw new Error("Rehearsal interrupted"); + if (predicate()) return; + await delay(); + } + throw new Error(`Timed out: ${label}`); +} + +try { + execute("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + path.join(root, "key.pem"), + "-out", + cert, + "-days", + "1", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ]); + fingerprint = execute("openssl", [ + "x509", + "-in", + cert, + "-noout", + "-fingerprint", + "-sha1", + ]) + .split("=")[1] + .replaceAll(":", ""); + execute("security", [ + "add-trusted-cert", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + path.join(os.homedir(), "Library/Keychains/login.keychain-db"), + cert, + ]); + trusted = true; + const signingKey = path.join(root, "updater.key"); + execute("pnpm", [ + "exec", + "tauri", + "signer", + "generate", + "--ci", + "-p", + "", + "-w", + signingKey, + ]); + const pubkey = fs.readFileSync(`${signingKey}.pub`, "utf8").trim(); + execute("ditto", [source, app]); + const compatibility = { + storeContractVersion: 1, + writesDataEpoch: 1, + minReadableDataEpoch: 1, + maxReadableDataEpoch: 1, + }; + const endpoint = "https://localhost:14443/latest.json"; + fs.writeFileSync( + path.join(app, "Contents/Resources/release-channels.json"), + JSON.stringify({ + schemaVersion: 1, + defaultChannel: "main", + runningBuild: { channelId: "main", compatibility }, + channels: [ + { id: "main", label: "Main", endpoint, pubkey, compatibility }, + ], + }), + ); + execute("codesign", ["--force", "--deep", "--sign", "-", app]); + execute("ditto", [app, target]); + execute("/usr/libexec/PlistBuddy", [ + "-c", + `Set CFBundleShortVersionString ${version}`, + path.join(target, "Contents/Info.plist"), + ]); + fs.writeFileSync( + path.join(target, "Contents/Resources/updater-test-proof.txt"), + "signed replacement payload\n", + ); + execute("codesign", ["--force", "--deep", "--sign", "-", target]); + execute("tar", ["-C", targetParent, "-czf", archive, "Berd.app"], { + COPYFILE_DISABLE: "1", + }); + const signing = { + TAURI_SIGNING_PRIVATE_KEY: fs.readFileSync(signingKey, "utf8"), + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "", + BERD_STORE_CONTRACT_VERSION: "1", + BERD_WRITES_DATA_EPOCH: "1", + BERD_MIN_READABLE_DATA_EPOCH: "1", + BERD_MAX_READABLE_DATA_EPOCH: "1", + }; + execute("pnpm", ["exec", "tauri", "signer", "sign", archive], signing); + const digest = execute("shasum", ["-a", "256", archive]).split(" ")[0]; + const descriptorSignature = execute( + "bash", + [ + "scripts/release/sign-compatibility-descriptor.sh", + version, + "main", + digest, + ], + signing, + ); + const manifest = execute( + "bash", + [ + "scripts/release/generate-latest-json.sh", + version, + "isolated signed replacement", + platform, + `${archive}.sig`, + `https://localhost:14443/${path.basename(archive)}`, + ], + { + ...signing, + BERD_RELEASE_CHANNEL_ID: "main", + BERD_ARTIFACT_SHA256: digest, + BERD_COMPATIBILITY_SIGNATURE: descriptorSignature, + }, + ); + fs.writeFileSync(path.join(root, "latest.json"), manifest); + let manifestRequests = 0, + archiveRequests = 0, + archiveCompleted = false; + server = https.createServer( + { + key: fs.readFileSync(path.join(root, "key.pem")), + cert: fs.readFileSync(cert), + }, + (request, response) => { + if (request.url === "/latest.json") { + manifestRequests++; + response.setHeader("Content-Type", "application/json"); + response.end(manifest); + } else if (request.url === `/${path.basename(archive)}`) { + archiveRequests++; + response.once("finish", () => { + archiveCompleted = true; + }); + fs.createReadStream(archive).pipe(response); + } else { + response.statusCode = 404; + response.end(); + } + }, + ); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(14443, "127.0.0.1", resolve); + }); + const lockPath = path.join( + os.homedir(), + "Library/Caches/Berd/berd-call-app-update.lock", + ); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + lock = spawn( + "/usr/bin/python3", + [ + "-u", + "-c", + "import fcntl,sys; f=open(sys.argv[1],'a+'); fcntl.flock(f,fcntl.LOCK_SH); print('LOCKED',flush=True); sys.stdin.read()", + lockPath, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + await new Promise((resolve, reject) => { + lock.stdout.once("data", resolve); + lock.once("error", reject); + lock.once("exit", () => + reject(new Error("Lock holder exited before readiness")), + ); + }); + execute("open", [ + "-g", + "-n", + "-a", + app, + "--env", + "BERD_E2E_MODE=1", + "--env", + `BERD_E2E_RUN_ID=${runId}`, + "--env", + `BERD_E2E_RUN_ROOT=${runRoot}`, + "--env", + `APP_TEST_DRIVER_TOKEN=${randomBytes(24).toString("hex")}`, + "--stdout", + path.join(runRoot, "stdout.log"), + "--stderr", + path.join(runRoot, "stderr.log"), + "berd://update-check", + ]); + await until( + () => fs.existsSync(path.join(runRoot, "app-test-driver.json")), + "driver readiness", + ); + pid = JSON.parse( + fs.readFileSync(path.join(runRoot, "app-test-driver.json"), "utf8"), + ).pid; + await until(() => archiveCompleted, "signed archive download"); + const proof = path.join(app, "Contents/Resources/updater-test-proof.txt"); + await delay(2000); + if (fs.existsSync(proof)) + throw new Error("Bundle replaced while call-equivalent lock was held"); + console.log("SIGNED_ARCHIVE_DOWNLOADED_WITH_INSTALL_BLOCKED"); + lock.stdin.end(); + await until( + () => fs.existsSync(proof), + "bundle replacement after lock release", + ); + execute("codesign", ["--verify", "--deep", "--strict", app]); + const hash = (bundle) => + execute("shasum", [ + "-a", + "256", + path.join(bundle, "Contents/MacOS/berd-call"), + ]).split(" ")[0]; + if (hash(app) !== hash(target)) + throw new Error("Bundled CLI payload mismatch"); + console.log( + `SIGNED_REPLACEMENT_VERIFIED manifests=${manifestRequests} archives=${archiveRequests} evidence=${root} app=${app}`, + ); +} catch (error) { + console.log(`REHEARSAL_FAILED ${error.message} evidence=${root}`); + process.exitCode = 1; +} finally { + if (lock) lock.stdin.end(); + if (pid) { + // The isolated app may leave its backend child after SIGTERM. + let children = []; + try { + children = execute("pgrep", ["-P", String(pid)]) + .split("\n") + .filter(Boolean); + } catch {} + for (const child of children) { + try { + process.kill(Number(child), "SIGTERM"); + } catch {} + } + try { + process.kill(pid, "SIGTERM"); + } catch {} + } + if (server) server.close(); + if (trusted) { + execute("security", ["remove-trusted-cert", cert]); + execute("security", [ + "delete-certificate", + "-Z", + fingerprint, + path.join(os.homedir(), "Library/Keychains/login.keychain-db"), + ]); + console.log("TEST_CERTIFICATE_TRUST_REMOVED"); + } +} diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index d706a8499..c6748607b 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -11,12 +11,33 @@ small macOS host for default-device capture, playback, and transcript delivery. ## Command-line interface +On macOS, install [Berd](https://github.com/block/berd/releases) first. Its +application bundle includes `berd-call`; link that binary into a directory on +your `PATH` so Berd app updates also update the CLI: + +```sh +mkdir -p "$HOME/.local/bin" +ln -s "/Applications/Berd.app/Contents/MacOS/berd-call" "$HOME/.local/bin/berd-call" +``` + +Make sure `$HOME/.local/bin` is on your `PATH`. If Berd is installed somewhere +other than `/Applications`, replace the app path above. A standalone binary +built with Cargo is for development and does not receive Berd app updates. +When started from Berd's bundle, the CLI asks Berd to check for an update in +the background. Download can continue during the call; installation waits +until the call ends. An unavailable update service does not prevent the call. + +For a local development binary, run `just install-berd-call-dev` from the Berd +checkout. This builds the debug CLI, installs a copy at +`~/.local/libexec/berd-call-dev`, and links `~/.local/bin/berd-call` to it. The +installer accepts an existing link to Berd's bundled CLI and remembers its exact target, but refuses to overwrite an unrelated command. Run `just uninstall-berd-call-dev` to remove the development copy and restore the original app link, including a custom app location. If there was no original link, it links to a released bundled CLI in `/Applications` when available. Ensure `~/.local/bin` is on your `PATH`. + The standalone command exposes the host-facing runtime protocol plus speech, model-management, synthesis, and diagnostic tools. `berd-call start` runs a foreground call on macOS using the default input and output devices. Its -loopback-only control endpoint supports `speak`, `status`, and `stop`; it does -not add persisted host settings, a menu-bar process, an updater, or a second -implementation of the shared call runtime. +loopback-only control endpoint supports `speak`, `status`, and `stop`. The CLI +uses Berd's updater when it runs from the app bundle; it does not implement a +second updater or call runtime. ```text berd-call --help diff --git a/src-tauri/crates/berd-call/src/lib.rs b/src-tauri/crates/berd-call/src/lib.rs index 831260bec..161b45d98 100644 --- a/src-tauri/crates/berd-call/src/lib.rs +++ b/src-tauri/crates/berd-call/src/lib.rs @@ -37,6 +37,8 @@ pub mod spokesperson_voice_update; mod status_sounds; mod synthesis; mod tts; +#[cfg(target_os = "macos")] +pub mod update_guard; pub use audio_output::{wait_until_drained, PcmAudioOutput}; pub use configured_tts::{ diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 92974a01c..1e2e1693f 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -503,6 +503,8 @@ fn main() { std::process::exit(1); } #[cfg(target_os = "macos")] + let _update_guard = guard_and_request_bundled_app_update(); + #[cfg(target_os = "macos")] if let Err(error) = menu_bar::run(options) { eprintln!("berd-call start failed: {error}"); std::process::exit(1); @@ -643,6 +645,77 @@ fn main() { } } +#[cfg(target_os = "macos")] +fn resolved_executable_path(executable: &Path) -> PathBuf { + // Replacement may briefly remove the executable after this process has + // started. The symlink target or original path still identifies its bundle. + if let Ok(path) = executable.canonicalize() { + return path; + } + match std::fs::read_link(executable) { + Ok(target) if target.is_absolute() => target, + Ok(target) => executable + .parent() + .map(|parent| parent.join(target)) + .unwrap_or_else(|| executable.to_path_buf()), + Err(_) => executable.to_path_buf(), + } +} + +#[cfg(target_os = "macos")] +fn bundled_app_path(executable: &Path) -> Option { + let executable = resolved_executable_path(executable); + if executable.file_name()?.to_str()? != "berd-call" { + return None; + } + let macos = executable.parent()?; + if macos.file_name()?.to_str()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()?.to_str()? != "Contents" { + return None; + } + let app = contents.parent()?; + (app.file_name()?.to_str()? == "Berd.app").then(|| app.to_path_buf()) +} + +#[cfg(target_os = "macos")] +fn guard_and_request_bundled_app_update() -> Option { + let app_path = std::env::current_exe() + .ok() + .and_then(|executable| bundled_app_path(&executable))?; + let guard = match berd_call::update_guard::hold_call() { + Ok(guard) => guard, + Err(error) => { + eprintln!("berd-call could not guard app updates during this call: {error}"); + return None; + } + }; + request_bundled_app_update(&app_path); + Some(guard) +} + +#[cfg(target_os = "macos")] +fn request_bundled_app_update(app_path: &Path) { + // `open -g` returns promptly and routes the URL to an already-running app, + // or starts this same bundle in the background. Berd owns update trust. + match std::process::Command::new("/usr/bin/open") + .arg("-g") + .arg("-a") + .arg(app_path) + .arg("berd://update-check") + .spawn() + { + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Err(error) => eprintln!("berd-call could not request a background app update: {error}"), + } +} + /// Where a call's transcript records go. #[derive(Clone, Copy, Debug, PartialEq)] enum TranscriptDestination { @@ -7385,6 +7458,55 @@ mod tests { use std::os::unix::net::UnixStream; use std::sync::Mutex; + #[cfg(target_os = "macos")] + #[test] + fn update_check_only_targets_the_owning_app_bundle() { + let directory = tempfile::tempdir().unwrap(); + let app = directory.path().join("Berd.app"); + let bundle = app.join("Contents/MacOS/berd-call"); + std::fs::create_dir_all(bundle.parent().unwrap()).unwrap(); + std::fs::write(&bundle, []).unwrap(); + assert_eq!(bundled_app_path(&bundle), Some(app.canonicalize().unwrap())); + assert!(bundled_app_path(Path::new("/tmp/berd-call")).is_none()); + let other = bundle.with_file_name("other"); + std::fs::write(&other, []).unwrap(); + assert!(bundled_app_path(&other).is_none()); + } + + #[cfg(target_os = "macos")] + #[test] + fn update_check_recognizes_a_path_symlink_to_the_bundled_cli() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let app = directory.path().join("Berd.app"); + let executable = app.join("Contents/MacOS/berd-call"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, []).unwrap(); + let link = directory.path().join("berd-call"); + symlink(&executable, &link).unwrap(); + + assert_eq!(bundled_app_path(&link), Some(app.canonicalize().unwrap())); + } + + #[cfg(target_os = "macos")] + #[test] + fn update_lock_still_recognizes_a_bundle_during_replacement() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let app = directory.path().join("Berd.app"); + let executable = app.join("Contents/MacOS/berd-call"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, []).unwrap(); + let link = directory.path().join("berd-call"); + symlink(&executable, &link).unwrap(); + + std::fs::remove_file(&executable).unwrap(); + assert_eq!(bundled_app_path(&link), Some(app.clone())); + assert_eq!(bundled_app_path(&executable), Some(app)); + } + #[test] fn status_cues_ignore_pending_recognition_but_suppress_actual_audio() { let mut core = SessionCore::default(); diff --git a/src-tauri/crates/berd-call/src/update_guard.rs b/src-tauri/crates/berd-call/src/update_guard.rs new file mode 100644 index 000000000..4acbcbc55 --- /dev/null +++ b/src-tauri/crates/berd-call/src/update_guard.rs @@ -0,0 +1,57 @@ +//! Coordinates a running standalone call with installation of a Berd app update. + +use fs2::FileExt; +use std::fs::{File, OpenOptions}; +use std::io; +use std::path::Path; + +fn lock_file() -> io::Result { + // Use a stable user path: CLI and GUI launch environments may disagree on + // TMPDIR, but both have the same home directory. + let home = std::env::var_os("HOME") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is unavailable"))?; + let directory = std::path::PathBuf::from(home).join("Library/Caches/Berd"); + std::fs::create_dir_all(&directory)?; + let path = directory.join("berd-call-app-update.lock"); + lock_file_at(&path) +} + +fn lock_file_at(path: &Path) -> io::Result { + OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) +} + +/// Hold while the CLI session is alive, including its internal restarts. +pub fn hold_call() -> io::Result { + let file = lock_file()?; + FileExt::lock_shared(&file)?; + Ok(file) +} + +/// Hold across app-bundle replacement so a call cannot start mid-install. +pub fn wait_until_no_call() -> io::Result { + let file = lock_file()?; + FileExt::lock_exclusive(&file)?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_call_prevents_install_until_it_ends() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("update.lock"); + let call = lock_file_at(&path).unwrap(); + FileExt::lock_shared(&call).unwrap(); + let installer = lock_file_at(&path).unwrap(); + assert!(FileExt::try_lock_exclusive(&installer).is_err()); + drop(call); + FileExt::try_lock_exclusive(&installer).unwrap(); + } +} diff --git a/src-tauri/src/commands/updates.rs b/src-tauri/src/commands/updates.rs index 0e55189d2..87c2d980c 100644 --- a/src-tauri/src/commands/updates.rs +++ b/src-tauri/src/commands/updates.rs @@ -949,6 +949,15 @@ pub async fn download_and_install_release( "The downloaded update does not match its signed compatibility descriptor".to_string(), ); } + // The bundled CLI may have requested this download when its call began. + // Installation replaces that same app bundle, so wait until every call + // finishes before touching it. Keep the lock through `install`. + #[cfg(target_os = "macos")] + let _call_guard = + tauri::async_runtime::spawn_blocking(berd_call::update_guard::wait_until_no_call) + .await + .map_err(|error| format!("Could not wait for the voice call: {error}"))? + .map_err(|error| format!("Could not guard the app update: {error}"))?; #[cfg(target_os = "windows")] { let mut persisted = state.0.persisted.lock().await; diff --git a/src-tauri/src/deep_links.rs b/src-tauri/src/deep_links.rs index f94264f54..47ce9b3a5 100644 --- a/src-tauri/src/deep_links.rs +++ b/src-tauri/src/deep_links.rs @@ -1,12 +1,13 @@ use percent_encoding::percent_decode_str; #[cfg(feature = "berdctl")] use serde::Serialize; -#[cfg(feature = "berdctl")] use tauri::Emitter; use tauri::{AppHandle, Manager, Runtime}; use tauri_plugin_deep_link::DeepLinkExt; use url::Url; +const CHECK_UPDATE_EVENT: &str = "berd:check-update"; + #[cfg(feature = "berdctl")] const SESSION_DEEP_LINK_ERROR_EVENT: &str = "berd:session-deep-link-error"; @@ -30,15 +31,35 @@ pub(crate) fn install(app: &tauri::App) { fn handle_urls(app: AppHandle, urls: Vec) { let mut opened_session = false; + let mut requested_update = false; for url in urls { log::info!("Received deep link: {url}"); + if is_update_check_link(&url) { + requested_update = true; + if let Err(error) = app.emit(CHECK_UPDATE_EVENT, ()) { + log::warn!("Failed to request a background update check: {error}"); + } + continue; + } if !opened_session { if let Some(session_id) = parse_session_deep_link(&url) { opened_session = open_session(app.clone(), session_id); } } } - focus_main_window(&app, opened_session); + // A background update request alone must not bring Berd to the foreground. + let update_check_only = requested_update && !opened_session; + if !update_check_only { + focus_main_window(&app, opened_session); + } +} + +fn is_update_check_link(url: &Url) -> bool { + url.scheme() == "berd" + && url.host_str() == Some("update-check") + && matches!(url.path(), "" | "/") + && url.query().is_none() + && url.fragment().is_none() } fn focus_main_window(app: &AppHandle, reveal: bool) { @@ -163,6 +184,19 @@ mod tests { parse_session_deep_link(&Url::parse(raw).unwrap()) } + #[test] + fn only_the_update_check_route_requests_an_update() { + assert!(is_update_check_link( + &Url::parse("berd://update-check").unwrap() + )); + assert!(!is_update_check_link( + &Url::parse("berd://update-check/other").unwrap() + )); + assert!(!is_update_check_link( + &Url::parse("https://update-check").unwrap() + )); + } + #[test] fn parses_session_host_route() { assert_eq!(parse("berd://session/abc-123"), Some("abc-123".to_string())); diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 07a903c0e..e80f35e07 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -17,5 +17,14 @@ } } ] + }, + "bundle": { + "externalBin": [ + "binaries/goosed", + "binaries/berdctl", + "binaries/berd-call", + "binaries/berd-monitor", + "binaries/catch" + ] } } diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index fd1c52097..8b4426bb1 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -6,6 +6,8 @@ import { App } from "./App"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; const mocks = vi.hoisted(() => ({ + startupUrls: vi.fn(), + showWindow: vi.fn(), appShellRender: vi.fn(), buildFeatures: { authGate: false, @@ -29,6 +31,13 @@ const mocks = vi.hoisted(() => ({ ), })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ + getCurrent: mocks.startupUrls, +})); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ show: mocks.showWindow }), +})); + vi.mock("@/features/auth/api/auth", () => ({ cancelLogin: mocks.cancelLogin, getAuthStatus: mocks.getAuthStatus, @@ -81,6 +90,8 @@ describe("App", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.startupUrls.mockResolvedValue(null); + mocks.showWindow.mockResolvedValue(undefined); mocks.buildFeatures.authGate = false; mediaPlayMock = vi .spyOn(window.HTMLMediaElement.prototype, "play") @@ -105,6 +116,7 @@ describe("App", () => { }); afterEach(() => { + vi.useRealTimers(); mediaPlayMock.mockRestore(); vi.unstubAllGlobals(); }); @@ -126,6 +138,44 @@ describe("App", () => { return { promise, resolve }; } + it("keeps an update-only cold launch hidden", async () => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + mocks.startupUrls.mockResolvedValue(["berd://update-check"]); + renderApp({ authGate: false }); + await waitFor(() => expect(mocks.startupUrls).toHaveBeenCalled()); + expect(mocks.showWindow).not.toHaveBeenCalled(); + }); + + it("shows the window for an ordinary launch", async () => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + renderApp({ authGate: false }); + await waitFor(() => expect(mocks.showWindow).toHaveBeenCalled()); + }); + + it("reveals an ordinary launch when startup URL discovery never settles", async () => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + mocks.startupUrls.mockReturnValue(new Promise(() => {})); + vi.useFakeTimers(); + renderApp({ authGate: false }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(mocks.startupUrls).toHaveBeenCalled(); + expect(mocks.showWindow).toHaveBeenCalledTimes(1); + }); + + it("does not reveal a confirmed update-only launch after the fallback", async () => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + mocks.startupUrls.mockResolvedValue(["berd://update-check"]); + vi.useFakeTimers(); + renderApp({ authGate: false }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(mocks.startupUrls).toHaveBeenCalled(); + expect(mocks.showWindow).not.toHaveBeenCalled(); + }); + it("prevents default window navigation when files are dragged into the app", async () => { vi.stubGlobal( "DragEvent", diff --git a/src/app/App.tsx b/src/app/App.tsx index 291058817..e9b93f2c2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -28,10 +28,28 @@ export function App() { // Dynamic import to avoid crash in non-Tauri environments (e.g., Playwright E2E) if (window.__TAURI_INTERNALS__) { - import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { - getCurrentWindow() - .show() - .catch(() => {}); + void Promise.all([ + import("@tauri-apps/api/window"), + import("@tauri-apps/plugin-deep-link"), + ]).then(async ([{ getCurrentWindow }, { getCurrent }]) => { + let timeout: ReturnType | undefined; + const urls = await Promise.race([ + getCurrent().catch(() => null), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), 1000); + }), + ]); + clearTimeout(timeout); + const updateOnly = + urls?.length && + urls.every( + (url) => + url === "berd://update-check" || url === "berd://update-check/", + ); + if (!updateOnly) + await getCurrentWindow() + .show() + .catch(() => {}); }); } diff --git a/src/features/updates/hooks/UpdaterProvider.tsx b/src/features/updates/hooks/UpdaterProvider.tsx index 302742474..d66aed037 100644 --- a/src/features/updates/hooks/UpdaterProvider.tsx +++ b/src/features/updates/hooks/UpdaterProvider.tsx @@ -13,8 +13,12 @@ import { toast } from "sonner"; import type { Update as TauriUpdate } from "@tauri-apps/plugin-updater"; import { Update as TauriUpdateResource } from "@tauri-apps/plugin-updater"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { probeKgooseConnectivity } from "@/shared/api/connectivity"; +const CHECK_UPDATE_EVENT = "berd:check-update"; + export type UpdateStatus = | "unavailable" | "idle" @@ -225,6 +229,7 @@ export function UpdaterProvider({ const switchUpdateRef = useRef(null); const switchUpdateRidRef = useRef(null); const checkPromiseRef = useRef | null>(null); + const startupUpdateHandledRef = useRef(false); const installPromiseRef = useRef | null>(null); const setStatusValue = useCallback((nextStatus: UpdateStatus) => { @@ -594,6 +599,45 @@ export function UpdaterProvider({ return () => window.clearInterval(interval); }, [checkForUpdate, checkIntervalMs, nativeUpdaterEnabled, runStartupCheck]); + useEffect(() => { + if (!nativeUpdaterEnabled) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void listen(CHECK_UPDATE_EVENT, () => { + if (cancelled) return; + startupUpdateHandledRef.current = true; + void checkForUpdate({ background: true, quiet: true }); + }) + .then(async (nextUnlisten) => { + if (cancelled) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + // Register first so a warm event racing the startup drain is not lost. + const urls = await getCurrent(); + if (cancelled || startupUpdateHandledRef.current) return; + if ( + urls?.some( + (raw) => + raw === "berd://update-check" || raw === "berd://update-check/", + ) + ) { + startupUpdateHandledRef.current = true; + void checkForUpdate({ background: true, quiet: true }); + } + }) + .catch((error) => { + console.warn( + `[updater] could not listen for CLI update requests: ${getErrorMessage(error)}`, + ); + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [checkForUpdate, nativeUpdaterEnabled]); + const value = useMemo( () => ({ status, diff --git a/src/features/updates/hooks/__tests__/useUpdater.test.tsx b/src/features/updates/hooks/__tests__/useUpdater.test.tsx index 9551cfc41..6158b92fe 100644 --- a/src/features/updates/hooks/__tests__/useUpdater.test.tsx +++ b/src/features/updates/hooks/__tests__/useUpdater.test.tsx @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Update as TauriUpdate } from "@tauri-apps/plugin-updater"; import { relaunch as tauriRelaunch } from "@tauri-apps/plugin-process"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { toast } from "sonner"; import { probeKgooseConnectivity } from "@/shared/api/connectivity"; import { I18nProvider } from "@/shared/i18n"; @@ -39,6 +41,10 @@ vi.mock("@tauri-apps/plugin-updater", () => ({ vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: vi.fn() })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ getCurrent: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); vi.mock("@/shared/api/connectivity", () => ({ probeKgooseConnectivity: vi.fn(), })); @@ -103,6 +109,8 @@ function wrapper({ describe("UpdaterProvider", () => { beforeEach(() => { mockUpdateInstances.length = 0; + vi.mocked(getCurrent).mockResolvedValue(null); + vi.mocked(listen).mockResolvedValue(() => {}); vi.mocked(invoke).mockImplementation((command) => { if (command === "get_release_runtime") return Promise.resolve(runtime); return Promise.reject(new Error(`unexpected invoke: ${command}`)); @@ -146,6 +154,85 @@ describe("UpdaterProvider", () => { expect(result.current.status).toBe("up-to-date"); }); + it("checks quietly when the bundled CLI requests an update", async () => { + enableUpdaterRuntime(); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "get_release_runtime") return Promise.resolve(runtime); + if (command === "check_release_update") return Promise.resolve(null); + return Promise.reject(new Error(`unexpected invoke: ${command}`)); + }); + renderHook(() => useUpdaterContext(), { wrapper }); + await waitFor(() => + expect(listen).toHaveBeenCalledWith( + "berd:check-update", + expect.any(Function), + ), + ); + const onRequest = vi + .mocked(listen) + .mock.calls.find(([event]) => event === "berd:check-update")?.[1]; + expect(onRequest).toBeDefined(); + act(() => + onRequest?.({ event: "berd:check-update", id: 1, payload: null }), + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("check_release_update"), + ); + }); + + it("drains a cold-start CLI update request exactly once", async () => { + enableUpdaterRuntime(); + vi.mocked(getCurrent).mockResolvedValue(["berd://update-check"]); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "get_release_runtime") return Promise.resolve(runtime); + if (command === "check_release_update") return Promise.resolve(null); + return Promise.reject(new Error(`unexpected invoke: ${command}`)); + }); + const { rerender } = renderHook(() => useUpdaterContext(), { wrapper }); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("check_release_update"), + ); + rerender(); + expect( + vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === "check_release_update"), + ).toHaveLength(1); + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("deduplicates a warm update event racing the startup drain", async () => { + enableUpdaterRuntime(); + let finishDrain: (urls: string[]) => void = () => {}; + vi.mocked(getCurrent).mockReturnValue( + new Promise((resolve) => { + finishDrain = resolve; + }), + ); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "get_release_runtime") return Promise.resolve(runtime); + if (command === "check_release_update") return Promise.resolve(null); + return Promise.reject(new Error(`unexpected invoke: ${command}`)); + }); + renderHook(() => useUpdaterContext(), { wrapper }); + await waitFor(() => expect(getCurrent).toHaveBeenCalled()); + const onRequest = vi + .mocked(listen) + .mock.calls.find(([event]) => event === "berd:check-update")?.[1]; + await act(async () => { + onRequest?.({ event: "berd:check-update", id: 1, payload: null }); + }); + await act(async () => { + finishDrain(["berd://update-check"]); + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === "check_release_update"), + ).toHaveLength(1); + }); + it("downloads an available same-channel update", async () => { enableUpdaterRuntime(); vi.mocked(invoke).mockImplementation((command) => { diff --git a/tests/app-e2e/bundled-updater.tauri.json b/tests/app-e2e/bundled-updater.tauri.json new file mode 100644 index 000000000..e7b55a5a4 --- /dev/null +++ b/tests/app-e2e/bundled-updater.tauri.json @@ -0,0 +1,10 @@ +{ + "identifier": "xyz.block.berd.e2e.bundled-updater", + "bundle": { "createUpdaterArtifacts": false }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEEwQzlDNjI5RDQ0MDFBN0MKUldSOEdrRFVLY2JKb09tZ3dONHBUN1RTc2VWdjFqNURXdFZ4TzFrek4wMTFZd2EwK3pkVytRdHIK", + "endpoints": ["https://localhost:14443/latest.json"] + } + } +}