From 66a82c6de2fa40c84ab58b901a62c98dfc0ab52e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 14:42:03 -0400 Subject: [PATCH 01/16] feat(berd-call): bundle CLI and request app updates at call start --- justfile | 2 +- scripts/prepare-berdctl-sidecar.sh | 28 +++++++- .../release/tests/release-scripts.test.mjs | 20 ++++++ src-tauri/crates/berd-call/README.md | 18 ++++- src-tauri/crates/berd-call/src/lib.rs | 2 + src-tauri/crates/berd-call/src/main.rs | 67 +++++++++++++++++++ .../crates/berd-call/src/update_guard.rs | 57 ++++++++++++++++ src-tauri/src/commands/updates.rs | 9 +++ src-tauri/src/deep_links.rs | 34 +++++++++- src-tauri/tauri.conf.json | 1 + .../updates/hooks/UpdaterProvider.tsx | 23 +++++++ .../hooks/__tests__/useUpdater.test.tsx | 31 +++++++++ 12 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 src-tauri/crates/berd-call/src/update_guard.rs diff --git a/justfile b/justfile index 27c54a6b1..edf107d45 100644 --- a/justfile +++ b/justfile @@ -630,7 +630,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/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index 0f9e4838e..bdbf8ceae 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, berd-call, and berd-monitor workspace crates in release mode and +copies their binaries into src-tauri/binaries with the target 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 } @@ -31,6 +34,9 @@ fi EXPLICIT_TRIPLE="${1:-${BERDCTL_TRIPLE:-}}" CARGO_ARGS=(build -p berdctl -p berd-monitor --release) +if [[ "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then + CARGO_ARGS+=(-p berd-call) +fi if [[ "${VITE_FEEDBACK:-0}" == "1" ]]; then CARGO_ARGS+=(--features berdctl/block-feedback) fi @@ -77,6 +83,22 @@ cp "$BUILT" "$OUT" chmod +x "$OUT" echo "Staged berdctl sidecar: $OUT" +if [[ "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then + if [[ -n "$EXPLICIT_TRIPLE" ]]; then + CALL_BUILT="$TARGET_DIR/$TRIPLE/release/berd-call" + else + CALL_BUILT="$TARGET_DIR/release/berd-call" + fi + if [[ ! -x "$CALL_BUILT" ]]; then + echo "Built berd-call binary not found at: $CALL_BUILT" >&2 + exit 1 + fi + CALL_OUT="$OUT_DIR/berd-call-$TRIPLE" + cp "$CALL_BUILT" "$CALL_OUT" + chmod +x "$CALL_OUT" + echo "Staged berd-call sidecar: $CALL_OUT" +fi + if [[ -n "$EXPLICIT_TRIPLE" ]]; then MONITOR_BUILT="$TARGET_DIR/$TRIPLE/release/berd-monitor" else diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index c776793f4..cbdc09595 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -562,6 +562,26 @@ 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.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('CALL_OUT="$OUT_DIR/berd-call-$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"), diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index d706a8499..024347af2 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -11,11 +11,27 @@ 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. + 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 +not add persisted host settings, a separate updater, or a second implementation of the shared call runtime. ```text 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..a3c938aeb 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -498,11 +498,23 @@ fn main() { Some("start") => { let options = parse_or_exit(parse_saved_start_args(&args), &args); #[cfg(target_os = "macos")] + let _update_guard = match berd_call::update_guard::hold_call() { + Ok(guard) => Some(guard), + Err(error) => { + eprintln!("berd-call could not guard app updates during this call: {error}"); + None + } + }; + #[cfg(target_os = "macos")] if let Err(error) = host_session::route_stop_signals(options.port) { eprintln!("berd-call start failed: {error}"); std::process::exit(1); } #[cfg(target_os = "macos")] + if _update_guard.is_some() { + 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 +655,49 @@ fn main() { } } +#[cfg(target_os = "macos")] +fn bundled_app_path(executable: &Path) -> Option { + 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 request_bundled_app_update() { + let Some(app_path) = std::env::current_exe() + .ok() + .and_then(|executable| bundled_app_path(&executable)) + else { + return; + }; + // `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 +7440,18 @@ 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 bundle = Path::new("/Applications/Berd.app/Contents/MacOS/berd-call"); + assert_eq!( + bundled_app_path(bundle), + Some(PathBuf::from("/Applications/Berd.app")) + ); + assert!(bundled_app_path(Path::new("/tmp/berd-call")).is_none()); + assert!(bundled_app_path(Path::new("/Applications/Berd.app/Contents/MacOS/other")).is_none()); + } + #[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..43c2e77ab --- /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) + .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()?; + file.lock_shared()?; + 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()?; + file.lock_exclusive()?; + 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(); + call.lock_shared().unwrap(); + let installer = lock_file_at(&path).unwrap(); + assert!(installer.try_lock_exclusive().is_err()); + drop(call); + installer.try_lock_exclusive().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..ace4835ef 100644 --- a/src-tauri/src/deep_links.rs +++ b/src-tauri/src/deep_links.rs @@ -1,7 +1,6 @@ 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; @@ -30,15 +29,33 @@ 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("berd:check-update", ()) { + 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); + if !requested_update || opened_session { + 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 +180,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.conf.json b/src-tauri/tauri.conf.json index ef00b5e22..64c790371 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -86,6 +86,7 @@ "externalBin": [ "binaries/goosed", "binaries/berdctl", + "binaries/berd-call", "binaries/berd-monitor", "binaries/catch" ], diff --git a/src/features/updates/hooks/UpdaterProvider.tsx b/src/features/updates/hooks/UpdaterProvider.tsx index 302742474..c67e832f5 100644 --- a/src/features/updates/hooks/UpdaterProvider.tsx +++ b/src/features/updates/hooks/UpdaterProvider.tsx @@ -13,6 +13,7 @@ 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 { probeKgooseConnectivity } from "@/shared/api/connectivity"; export type UpdateStatus = @@ -594,6 +595,28 @@ export function UpdaterProvider({ return () => window.clearInterval(interval); }, [checkForUpdate, checkIntervalMs, nativeUpdaterEnabled, runStartupCheck]); + useEffect(() => { + if (!nativeUpdaterEnabled) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void listen("berd:check-update", () => { + void checkForUpdate({ background: true, quiet: true }); + }) + .then((nextUnlisten) => { + if (cancelled) nextUnlisten(); + else unlisten = nextUnlisten; + }) + .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..77ecb4756 100644 --- a/src/features/updates/hooks/__tests__/useUpdater.test.tsx +++ b/src/features/updates/hooks/__tests__/useUpdater.test.tsx @@ -4,6 +4,7 @@ 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 { toast } from "sonner"; import { probeKgooseConnectivity } from "@/shared/api/connectivity"; import { I18nProvider } from "@/shared/i18n"; @@ -39,6 +40,9 @@ 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/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); vi.mock("@/shared/api/connectivity", () => ({ probeKgooseConnectivity: vi.fn(), })); @@ -103,6 +107,7 @@ function wrapper({ describe("UpdaterProvider", () => { beforeEach(() => { mockUpdateInstances.length = 0; + 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 +151,32 @@ 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("downloads an available same-channel update", async () => { enableUpdaterRuntime(); vi.mocked(invoke).mockImplementation((command) => { From 0be8ed09267deaa4e827ae9cf22cfcda82aa827c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 14:47:51 -0400 Subject: [PATCH 02/16] docs(berd-call): describe the app-owned updater accurately --- src-tauri/crates/berd-call/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 024347af2..2aa1ccfbe 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -30,9 +30,9 @@ until the call ends. An unavailable update service does not prevent the call. 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 separate 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 From 066e38a5d5d9ca47d29752e52f097f64ef7df58e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 14:56:53 -0400 Subject: [PATCH 03/16] fix(berd-call): scope bundled CLI to macOS releases --- scripts/prepare-berdctl-sidecar.sh | 12 ++++++------ scripts/release/tests/release-scripts.test.mjs | 2 +- src-tauri/crates/berd-call/src/update_guard.rs | 1 + src-tauri/tauri.conf.json | 1 - src-tauri/tauri.macos.conf.json | 9 +++++++++ 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index bdbf8ceae..044182923 100755 --- a/scripts/prepare-berdctl-sidecar.sh +++ b/scripts/prepare-berdctl-sidecar.sh @@ -13,8 +13,8 @@ usage() { cat <<'USAGE' Usage: scripts/prepare-berdctl-sidecar.sh [target-triple] -Builds the berdctl, berd-call, and berd-monitor workspace crates in release mode and -copies their 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 @@ -34,9 +34,6 @@ fi EXPLICIT_TRIPLE="${1:-${BERDCTL_TRIPLE:-}}" CARGO_ARGS=(build -p berdctl -p berd-monitor --release) -if [[ "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then - CARGO_ARGS+=(-p berd-call) -fi if [[ "${VITE_FEEDBACK:-0}" == "1" ]]; then CARGO_ARGS+=(--features berdctl/block-feedback) fi @@ -50,6 +47,9 @@ else exit 1 fi fi +if [[ "$TRIPLE" == *apple-darwin && "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then + CARGO_ARGS+=(-p berd-call) +fi (cd src-tauri && cargo "${CARGO_ARGS[@]}") @@ -83,7 +83,7 @@ cp "$BUILT" "$OUT" chmod +x "$OUT" echo "Staged berdctl sidecar: $OUT" -if [[ "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then +if [[ "$TRIPLE" == *apple-darwin && "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then if [[ -n "$EXPLICIT_TRIPLE" ]]; then CALL_BUILT="$TARGET_DIR/$TRIPLE/release/berd-call" else diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index cbdc09595..c0309a261 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -564,7 +564,7 @@ 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.conf.json"), "utf8"), + await readFile(join(repo, "src-tauri/tauri.macos.conf.json"), "utf8"), ); const stage = await readFile( join(repo, "scripts/prepare-berdctl-sidecar.sh"), diff --git a/src-tauri/crates/berd-call/src/update_guard.rs b/src-tauri/crates/berd-call/src/update_guard.rs index 43c2e77ab..c6b68f7de 100644 --- a/src-tauri/crates/berd-call/src/update_guard.rs +++ b/src-tauri/crates/berd-call/src/update_guard.rs @@ -20,6 +20,7 @@ fn lock_file() -> io::Result { fn lock_file_at(path: &Path) -> io::Result { OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(path) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 64c790371..ef00b5e22 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -86,7 +86,6 @@ "externalBin": [ "binaries/goosed", "binaries/berdctl", - "binaries/berd-call", "binaries/berd-monitor", "binaries/catch" ], 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" + ] } } From 7e0533d1a9cf1bea02a4ece7bcff64bda65214dd Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 15:16:08 -0400 Subject: [PATCH 04/16] fix(berd-call): recognize bundled CLI through PATH symlinks --- scripts/prepare-berdctl-sidecar.sh | 4 +- src-tauri/crates/berd-call/src/main.rs | 66 ++++++++++++------- .../crates/berd-call/src/update_guard.rs | 13 ++-- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/scripts/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index 044182923..1499c7fa8 100755 --- a/scripts/prepare-berdctl-sidecar.sh +++ b/scripts/prepare-berdctl-sidecar.sh @@ -47,7 +47,9 @@ 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 @@ -83,7 +85,7 @@ cp "$BUILT" "$OUT" chmod +x "$OUT" echo "Staged berdctl sidecar: $OUT" -if [[ "$TRIPLE" == *apple-darwin && "${BERD_CALL_BUNDLE:-1}" == "1" ]]; then +if [[ "$BUNDLE_BERD_CALL" == "1" ]]; then if [[ -n "$EXPLICIT_TRIPLE" ]]; then CALL_BUILT="$TARGET_DIR/$TRIPLE/release/berd-call" else diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index a3c938aeb..ca0a729f6 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -498,23 +498,27 @@ fn main() { Some("start") => { let options = parse_or_exit(parse_saved_start_args(&args), &args); #[cfg(target_os = "macos")] - let _update_guard = match berd_call::update_guard::hold_call() { - Ok(guard) => Some(guard), - Err(error) => { - eprintln!("berd-call could not guard app updates during this call: {error}"); - None - } - }; + let _update_guard = std::env::current_exe() + .ok() + .and_then(|executable| bundled_app_path(&executable)) + .and_then(|app_path| match berd_call::update_guard::hold_call() { + Ok(guard) => { + request_bundled_app_update(&app_path); + Some(guard) + } + Err(error) => { + eprintln!( + "berd-call could not guard app updates during this call: {error}" + ); + None + } + }); #[cfg(target_os = "macos")] if let Err(error) = host_session::route_stop_signals(options.port) { eprintln!("berd-call start failed: {error}"); std::process::exit(1); } #[cfg(target_os = "macos")] - if _update_guard.is_some() { - 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); @@ -657,6 +661,7 @@ fn main() { #[cfg(target_os = "macos")] fn bundled_app_path(executable: &Path) -> Option { + let executable = executable.canonicalize().ok()?; if executable.file_name()?.to_str()? != "berd-call" { return None; } @@ -673,13 +678,7 @@ fn bundled_app_path(executable: &Path) -> Option { } #[cfg(target_os = "macos")] -fn request_bundled_app_update() { - let Some(app_path) = std::env::current_exe() - .ok() - .and_then(|executable| bundled_app_path(&executable)) - else { - return; - }; +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") @@ -7443,13 +7442,32 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn update_check_only_targets_the_owning_app_bundle() { - let bundle = Path::new("/Applications/Berd.app/Contents/MacOS/berd-call"); - assert_eq!( - bundled_app_path(bundle), - Some(PathBuf::from("/Applications/Berd.app")) - ); + 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()); - assert!(bundled_app_path(Path::new("/Applications/Berd.app/Contents/MacOS/other")).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())); } #[test] diff --git a/src-tauri/crates/berd-call/src/update_guard.rs b/src-tauri/crates/berd-call/src/update_guard.rs index c6b68f7de..4acbcbc55 100644 --- a/src-tauri/crates/berd-call/src/update_guard.rs +++ b/src-tauri/crates/berd-call/src/update_guard.rs @@ -10,8 +10,7 @@ fn lock_file() -> io::Result { // 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"); + 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) @@ -29,14 +28,14 @@ fn lock_file_at(path: &Path) -> io::Result { /// Hold while the CLI session is alive, including its internal restarts. pub fn hold_call() -> io::Result { let file = lock_file()?; - file.lock_shared()?; + 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()?; - file.lock_exclusive()?; + FileExt::lock_exclusive(&file)?; Ok(file) } @@ -49,10 +48,10 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("update.lock"); let call = lock_file_at(&path).unwrap(); - call.lock_shared().unwrap(); + FileExt::lock_shared(&call).unwrap(); let installer = lock_file_at(&path).unwrap(); - assert!(installer.try_lock_exclusive().is_err()); + assert!(FileExt::try_lock_exclusive(&installer).is_err()); drop(call); - installer.try_lock_exclusive().unwrap(); + FileExt::try_lock_exclusive(&installer).unwrap(); } } From 7c57af2f38a601835007e0725c7f2824cfd004c9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 15:26:54 -0400 Subject: [PATCH 05/16] fix(berd-call): retain update guard across bundle replacement --- src-tauri/crates/berd-call/src/main.rs | 64 +++++++++++++++++++------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index ca0a729f6..646fef28c 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -498,21 +498,7 @@ fn main() { Some("start") => { let options = parse_or_exit(parse_saved_start_args(&args), &args); #[cfg(target_os = "macos")] - let _update_guard = std::env::current_exe() - .ok() - .and_then(|executable| bundled_app_path(&executable)) - .and_then(|app_path| match berd_call::update_guard::hold_call() { - Ok(guard) => { - request_bundled_app_update(&app_path); - Some(guard) - } - Err(error) => { - eprintln!( - "berd-call could not guard app updates during this call: {error}" - ); - None - } - }); + let _update_guard = guard_and_request_bundled_app_update(); #[cfg(target_os = "macos")] if let Err(error) = host_session::route_stop_signals(options.port) { eprintln!("berd-call start failed: {error}"); @@ -661,7 +647,19 @@ fn main() { #[cfg(target_os = "macos")] fn bundled_app_path(executable: &Path) -> Option { - let executable = executable.canonicalize().ok()?; + // Replacement may briefly remove the executable after this process has + // started. Keep recognizing its bundle so the call still takes the lock. + let executable = executable.canonicalize().unwrap_or_else(|_| { + std::fs::read_link(executable) + .map(|target| { + if target.is_absolute() { + target + } else { + executable.parent().unwrap_or(Path::new("")).join(target) + } + }) + .unwrap_or_else(|_| executable.to_path_buf()) + }); if executable.file_name()?.to_str()? != "berd-call" { return None; } @@ -677,6 +675,22 @@ fn bundled_app_path(executable: &Path) -> Option { (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, @@ -7470,6 +7484,24 @@ mod tests { 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(); From b5b3b87cd0afa65ee06f3b2f95f13d81cac6da99 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 24 Sep 2026 15:35:44 -0400 Subject: [PATCH 06/16] refactor(berd-call): isolate bundle path resolution --- src-tauri/crates/berd-call/src/main.rs | 31 +++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 646fef28c..f8776a07b 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -646,20 +646,25 @@ fn main() { } #[cfg(target_os = "macos")] -fn bundled_app_path(executable: &Path) -> Option { +fn resolved_executable_path(executable: &Path) -> PathBuf { // Replacement may briefly remove the executable after this process has - // started. Keep recognizing its bundle so the call still takes the lock. - let executable = executable.canonicalize().unwrap_or_else(|_| { - std::fs::read_link(executable) - .map(|target| { - if target.is_absolute() { - target - } else { - executable.parent().unwrap_or(Path::new("")).join(target) - } - }) - .unwrap_or_else(|_| executable.to_path_buf()) - }); + // 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; } From 0570c730055368a969d05634350dd3be7990049a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 28 Sep 2026 12:34:03 -0400 Subject: [PATCH 07/16] fix(updater): omit AppleDouble metadata from macOS archives --- scripts/release/package-signed-updater.sh | 4 +++- .../release/tests/release-scripts.test.mjs | 23 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) 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 c0309a261..0d7a723f6 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, "../../.."); @@ -1107,7 +1108,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"); @@ -1161,6 +1162,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", From 501a281e2d4e4bc041f5c478a8b2d37f06e0fe34 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 28 Sep 2026 14:35:08 -0400 Subject: [PATCH 08/16] refactor(release): stage bundled CLIs through one helper --- scripts/prepare-berdctl-sidecar.sh | 56 ++++++------------- .../release/tests/release-scripts.test.mjs | 3 +- 2 files changed, 18 insertions(+), 41 deletions(-) diff --git a/scripts/prepare-berdctl-sidecar.sh b/scripts/prepare-berdctl-sidecar.sh index 1499c7fa8..0ed182741 100755 --- a/scripts/prepare-berdctl-sidecar.sh +++ b/scripts/prepare-berdctl-sidecar.sh @@ -66,53 +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 [[ "$BUNDLE_BERD_CALL" == "1" ]]; then +stage_cli() { + local name="$1" built out + # Cargo nests output under the triple only when --target is passed. if [[ -n "$EXPLICIT_TRIPLE" ]]; then - CALL_BUILT="$TARGET_DIR/$TRIPLE/release/berd-call" + built="$TARGET_DIR/$TRIPLE/release/$name" else - CALL_BUILT="$TARGET_DIR/release/berd-call" + built="$TARGET_DIR/release/$name" fi - if [[ ! -x "$CALL_BUILT" ]]; then - echo "Built berd-call binary not found at: $CALL_BUILT" >&2 + if [[ ! -x "$built" ]]; then + echo "Built $name binary not found at: $built" >&2 exit 1 fi - CALL_OUT="$OUT_DIR/berd-call-$TRIPLE" - cp "$CALL_BUILT" "$CALL_OUT" - chmod +x "$CALL_OUT" - echo "Staged berd-call sidecar: $CALL_OUT" -fi - -if [[ -n "$EXPLICIT_TRIPLE" ]]; then - MONITOR_BUILT="$TARGET_DIR/$TRIPLE/release/berd-monitor" -else - MONITOR_BUILT="$TARGET_DIR/release/berd-monitor" -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/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index 0d7a723f6..023c3f16f 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -577,7 +577,8 @@ describe("build-macos Block-service feature seam", () => { ); expect(config.bundle.externalBin).toContain("binaries/berd-call"); expect(stage).toContain("CARGO_ARGS+=(-p berd-call)"); - expect(stage).toContain('CALL_OUT="$OUT_DIR/berd-call-$TRIPLE"'); + 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"', ); From 8340f6b8aa795ee46d8eec9861b3bb918cc57e4a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 28 Sep 2026 14:41:37 -0400 Subject: [PATCH 09/16] refactor(updater): name background-only deep link routing --- src-tauri/src/deep_links.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/deep_links.rs b/src-tauri/src/deep_links.rs index ace4835ef..4ddf24190 100644 --- a/src-tauri/src/deep_links.rs +++ b/src-tauri/src/deep_links.rs @@ -45,7 +45,9 @@ fn handle_urls(app: AppHandle, urls: Vec) { } } } - if !requested_update || 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); } } From 124d226065380d15203b58cd08f9e17f1110be07 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 28 Sep 2026 14:47:14 -0400 Subject: [PATCH 10/16] refactor(updater): name update-check event contract --- src-tauri/src/deep_links.rs | 4 +++- src/features/updates/hooks/UpdaterProvider.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/deep_links.rs b/src-tauri/src/deep_links.rs index 4ddf24190..47ce9b3a5 100644 --- a/src-tauri/src/deep_links.rs +++ b/src-tauri/src/deep_links.rs @@ -6,6 +6,8 @@ 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"; @@ -34,7 +36,7 @@ fn handle_urls(app: AppHandle, urls: Vec) { log::info!("Received deep link: {url}"); if is_update_check_link(&url) { requested_update = true; - if let Err(error) = app.emit("berd:check-update", ()) { + if let Err(error) = app.emit(CHECK_UPDATE_EVENT, ()) { log::warn!("Failed to request a background update check: {error}"); } continue; diff --git a/src/features/updates/hooks/UpdaterProvider.tsx b/src/features/updates/hooks/UpdaterProvider.tsx index c67e832f5..d3402fb9f 100644 --- a/src/features/updates/hooks/UpdaterProvider.tsx +++ b/src/features/updates/hooks/UpdaterProvider.tsx @@ -16,6 +16,8 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { probeKgooseConnectivity } from "@/shared/api/connectivity"; +const CHECK_UPDATE_EVENT = "berd:check-update"; + export type UpdateStatus = | "unavailable" | "idle" @@ -599,7 +601,7 @@ export function UpdaterProvider({ if (!nativeUpdaterEnabled) return; let cancelled = false; let unlisten: (() => void) | undefined; - void listen("berd:check-update", () => { + void listen(CHECK_UPDATE_EVENT, () => { void checkForUpdate({ background: true, quiet: true }); }) .then((nextUnlisten) => { From 4417bd4324067e11d3a318b2920312378242eb31 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 1 Oct 2026 12:45:25 -0400 Subject: [PATCH 11/16] Add user-local Berd Call development install recipe --- justfile | 11 +++++++ scripts/install-berd-call-dev.sh | 47 ++++++++++++++++++++++++++++ src-tauri/crates/berd-call/README.md | 8 +++++ 3 files changed, 66 insertions(+) create mode 100644 scripts/install-berd-call-dev.sh diff --git a/justfile b/justfile index edf107d45..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. diff --git a/scripts/install-berd-call-dev.sh b/scripts/install-berd-call-dev.sh new file mode 100644 index 000000000..624dd1e2a --- /dev/null +++ b/scripts/install-berd-call-dev.sh @@ -0,0 +1,47 @@ +#!/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" + +case "$action" in + install) + [[ -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" && "$(readlink "$command_link")" == "$dev_binary" ]] || { + echo "Refusing to replace existing $command_link" >&2 + exit 1 + } + 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 + [[ -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 [[ -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/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 2aa1ccfbe..e609fe3c5 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -27,6 +27,14 @@ 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 refuses to overwrite an unrelated command. Run +`just uninstall-berd-call-dev` to remove that copy and link; if a released Berd +app with a bundled CLI is installed in `/Applications`, it restores the link +to that app. 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 From 881991c30ede4e3e4f7b9ddf07a40e683c18aece Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 5 Oct 2026 10:20:17 -0400 Subject: [PATCH 12/16] fix(updater): drain cold-start CLI update requests --- .../updates/hooks/UpdaterProvider.tsx | 25 ++++++++- .../hooks/__tests__/useUpdater.test.tsx | 56 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/features/updates/hooks/UpdaterProvider.tsx b/src/features/updates/hooks/UpdaterProvider.tsx index d3402fb9f..d66aed037 100644 --- a/src/features/updates/hooks/UpdaterProvider.tsx +++ b/src/features/updates/hooks/UpdaterProvider.tsx @@ -14,6 +14,7 @@ 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"; @@ -228,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) => { @@ -602,11 +604,28 @@ export function UpdaterProvider({ 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((nextUnlisten) => { - if (cancelled) nextUnlisten(); - else unlisten = nextUnlisten; + .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( diff --git a/src/features/updates/hooks/__tests__/useUpdater.test.tsx b/src/features/updates/hooks/__tests__/useUpdater.test.tsx index 77ecb4756..6158b92fe 100644 --- a/src/features/updates/hooks/__tests__/useUpdater.test.tsx +++ b/src/features/updates/hooks/__tests__/useUpdater.test.tsx @@ -5,6 +5,7 @@ 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"; @@ -40,6 +41,7 @@ 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(() => {}), })); @@ -107,6 +109,7 @@ 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); @@ -177,6 +180,59 @@ describe("UpdaterProvider", () => { ); }); + 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) => { From e23bf3db5c05313de95c39e247f25ae589534e47 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 5 Oct 2026 10:21:24 -0400 Subject: [PATCH 13/16] fix(cli): restore released link after dev uninstall --- scripts/install-berd-call-dev.sh | 30 ++++++++++++- scripts/install-berd-call-dev.test.mjs | 59 ++++++++++++++++++++++++++ src-tauri/crates/berd-call/README.md | 5 +-- 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 scripts/install-berd-call-dev.test.mjs diff --git a/scripts/install-berd-call-dev.sh b/scripts/install-berd-call-dev.sh index 624dd1e2a..f96e1692d 100644 --- a/scripts/install-berd-call-dev.sh +++ b/scripts/install-berd-call-dev.sh @@ -7,15 +7,31 @@ 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" && "$(readlink "$command_link")" == "$dev_binary" ]] || { + [[ -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")" @@ -23,6 +39,12 @@ case "$action" in 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" ;; @@ -32,7 +54,11 @@ case "$action" in exit 1 } rm "$command_link" - if [[ -x /Applications/Berd.app/Contents/MacOS/berd-call ]]; then + 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 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/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index e609fe3c5..c6748607b 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -30,10 +30,7 @@ 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 refuses to overwrite an unrelated command. Run -`just uninstall-berd-call-dev` to remove that copy and link; if a released Berd -app with a bundled CLI is installed in `/Applications`, it restores the link -to that app. Ensure `~/.local/bin` is on your `PATH`. +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 From 33ee73743bfedab75177274105f68a9739724742 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 5 Oct 2026 11:25:35 -0400 Subject: [PATCH 14/16] fix(updater): preserve background startup and signal routing --- src-tauri/crates/berd-call/src/main.rs | 4 ++-- src/app/App.test.tsx | 25 +++++++++++++++++++++++++ src/app/App.tsx | 19 +++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index f8776a07b..1e2e1693f 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -498,13 +498,13 @@ fn main() { Some("start") => { let options = parse_or_exit(parse_saved_start_args(&args), &args); #[cfg(target_os = "macos")] - let _update_guard = guard_and_request_bundled_app_update(); - #[cfg(target_os = "macos")] if let Err(error) = host_session::route_stop_signals(options.port) { eprintln!("berd-call start failed: {error}"); 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); diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index fd1c52097..8261dae45 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") @@ -126,6 +137,20 @@ 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("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..dd0c9438b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -28,10 +28,21 @@ 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 }]) => { + const urls = await getCurrent().catch(() => null); + const updateOnly = + urls?.length && + urls.every( + (url) => + url === "berd://update-check" || url === "berd://update-check/", + ); + if (!updateOnly) + await getCurrentWindow() + .show() + .catch(() => {}); }); } From 7286409b9a7e46ad3087583a59a15c76a57403c3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 5 Oct 2026 13:15:48 -0400 Subject: [PATCH 15/16] test(updater): make signed bundle rehearsal reproducible --- docs/berd-call-updater-testing.md | 51 ++++ scripts/test-bundled-updater.mjs | 336 +++++++++++++++++++++++ tests/app-e2e/bundled-updater.tauri.json | 10 + 3 files changed, 397 insertions(+) create mode 100644 docs/berd-call-updater-testing.md create mode 100644 scripts/test-bundled-updater.mjs create mode 100644 tests/app-e2e/bundled-updater.tauri.json 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/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/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"] + } + } +} From b296b40ad374d4ae70f3273da8614e2ece9ae6df Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 5 Oct 2026 14:26:16 -0400 Subject: [PATCH 16/16] fix(startup): bound deep-link window reveal wait --- src/app/App.test.tsx | 25 +++++++++++++++++++++++++ src/app/App.tsx | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 8261dae45..8b4426bb1 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -116,6 +116,7 @@ describe("App", () => { }); afterEach(() => { + vi.useRealTimers(); mediaPlayMock.mockRestore(); vi.unstubAllGlobals(); }); @@ -151,6 +152,30 @@ describe("App", () => { 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 dd0c9438b..e9b93f2c2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -32,7 +32,14 @@ export function App() { import("@tauri-apps/api/window"), import("@tauri-apps/plugin-deep-link"), ]).then(async ([{ getCurrentWindow }, { getCurrent }]) => { - const urls = await getCurrent().catch(() => null); + 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(