Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
66a82c6
feat(berd-call): bundle CLI and request app updates at call start
johnmatthewtennant Sep 24, 2026
0be8ed0
docs(berd-call): describe the app-owned updater accurately
johnmatthewtennant Sep 24, 2026
066e38a
fix(berd-call): scope bundled CLI to macOS releases
johnmatthewtennant Sep 24, 2026
7e0533d
fix(berd-call): recognize bundled CLI through PATH symlinks
johnmatthewtennant Sep 24, 2026
7c57af2
fix(berd-call): retain update guard across bundle replacement
johnmatthewtennant Sep 24, 2026
b5b3b87
refactor(berd-call): isolate bundle path resolution
johnmatthewtennant Sep 24, 2026
0570c73
fix(updater): omit AppleDouble metadata from macOS archives
johnmatthewtennant Sep 28, 2026
501a281
refactor(release): stage bundled CLIs through one helper
johnmatthewtennant Sep 28, 2026
8340f6b
refactor(updater): name background-only deep link routing
johnmatthewtennant Sep 28, 2026
124d226
refactor(updater): name update-check event contract
johnmatthewtennant Sep 28, 2026
4417bd4
Add user-local Berd Call development install recipe
johnmatthewtennant Oct 1, 2026
881991c
fix(updater): drain cold-start CLI update requests
johnmatthewtennant Oct 5, 2026
e23bf3d
fix(cli): restore released link after dev uninstall
johnmatthewtennant Oct 5, 2026
33ee737
fix(updater): preserve background startup and signal routing
johnmatthewtennant Oct 5, 2026
7286409
test(updater): make signed bundle rehearsal reproducible
johnmatthewtennant Oct 5, 2026
b296b40
fix(startup): bound deep-link window reveal wait
johnmatthewtennant Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/berd-call-updater-testing.md
Original file line number Diff line number Diff line change
@@ -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 <evidence>/cert.pem`. Read its fingerprint using `openssl x509 -in <evidence>/cert.pem -noout -fingerprint -sha1`, then remove that exact certificate using `security delete-certificate -Z <fingerprint-without-colons> ~/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.
13 changes: 12 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions scripts/install-berd-call-dev.sh
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions scripts/install-berd-call-dev.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
64 changes: 32 additions & 32 deletions scripts/prepare-berdctl-sidecar.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}

Expand All @@ -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[@]}")

Expand All @@ -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
4 changes: 3 additions & 1 deletion scripts/release/package-signed-updater.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 43 additions & 1 deletion scripts/release/tests/release-scripts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "../../..");
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading