#!/bin/sh # Anvilda Listen installer (core#35510, contract ยง9). Served as https://get.anvilda.com/listen. # # curl -fsSL https://get.anvilda.com/listen | sh # curl -fsSL https://get.anvilda.com/listen | sh -s -- --dry-run # # Read it first if you like: curl -fsSL https://get.anvilda.com/listen # # What it does, in order, stopping at the first thing that does not check out: # 1. checks this is an Apple Silicon Mac on macOS 13 or later; # 2. downloads the pinned Node.js runtime and checks its SHA-256 against the value pinned below; # 3. downloads the latest Anvilda Listen release and checks its SHA-256 and Ed25519 signature with # that runtime, against the public key pinned below; # 4. downloads the pinned cloudflared connector and checks its SHA-256; # 5. installs into ~/.anvilda-listen, runs `anvilda-listen connect` (pairing with the Anvilda # gateway: you confirm a short code in your browser), installs the two launchd agents # com.anvilda.listen and com.anvilda.listen.cloudflared, and opens the live screen. # --dry-run does steps 1-4 (every download and every check) and prints what it would install, # changing nothing on this Mac. # # Nothing here needs sudo. Every CEO starts with no access. Uninstall: anvilda-listen uninstall. set -eu # ---- pinned values (a release of this installer changes them deliberately, in review) ---------- LISTEN_BASE_URL="https://get.anvilda.com/listen" # Ed25519 release public key, base64 of the 32 raw bytes. Set at go-live by the owner step that creates # the signing key (scripts/listen-release.sh prints the value to pin). Until then this installer refuses. LISTEN_PUBKEY="pkBXRqDmFS6J4eTgDJayAzbJnoMFGJvUU5vOIpQ+TK4=" # Node.js runtime: https://nodejs.org/dist/v24.22.2/SHASUMS256.txt NODE_VERSION="24.22.2" NODE_SHA256="aa9549349e47dffaa42dcf95d6e06ade8c4bdafc980979883b88767670df385a" # cloudflared: https://github.com/cloudflare/cloudflared/releases/tag/2026.10.7 (asset digest, and the # binary hash from the release notes) CLOUDFLARED_VERSION="2026.10.7" CLOUDFLARED_TGZ_SHA256="3586ccf397e98e5f437e60e7c307ca6ac463e110580503af8bb8e6f9ebf831bd" CLOUDFLARED_BIN_SHA256="2136431d5eb467bf447d2ee514b713e192719b143aec1d9e09bf7dc290d5bb12" # Where `anvilda-listen connect` stores the tunnel token (macOS Keychain, generic password). KEYCHAIN_SERVICE="com.anvilda.listen" KEYCHAIN_TUNNEL_ACCOUNT="tunnel-token" SERVICE_LABEL="com.anvilda.listen" CONNECTOR_LABEL="com.anvilda.listen.cloudflared" # ----------------------------------------------------------------------------------------------- # Printed from a heredoc, not read back from $0: under `curl ... | sh -s -- --help`, $0 is just "sh". usage() { cat <<'USAGE' Anvilda Listen installer curl -fsSL https://get.anvilda.com/listen | sh curl -fsSL https://get.anvilda.com/listen | sh -s -- --dry-run Checks this is an Apple Silicon Mac on macOS 13 or later, downloads the pinned Node.js runtime and cloudflared (each checked against a pinned SHA-256), downloads the latest Anvilda Listen release and checks its Ed25519 signature, then installs into ~/.anvilda-listen, pairs this Mac with the Anvilda gateway (anvilda-listen connect), starts the launchd agents com.anvilda.listen and com.anvilda.listen.cloudflared, and opens the live screen. --dry-run download and check everything, change nothing, print what would be installed --help this text No sudo needed. Every CEO starts with no access. Uninstall: anvilda-listen uninstall. USAGE } DRY_RUN=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) echo "anvilda-listen install: unknown option $arg" >&2; exit 2 ;; esac done LISTEN_HOME="${LISTEN_HOME:-$HOME/.anvilda-listen}" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" say() { printf '==> %s\n' "$*"; } plan() { printf ' would %s\n' "$*"; } refuse() { printf 'anvilda-listen install: REFUSED: %s\n' "$*" >&2; exit 1; } # 1. This Mac. [ "$(uname -s)" = "Darwin" ] || refuse "Anvilda Listen runs on macOS only." [ "$(uname -m)" = "arm64" ] || refuse "Anvilda Listen needs an Apple Silicon (arm64) Mac; this one is $(uname -m)." MACOS_VERSION="$(sw_vers -productVersion 2>/dev/null || echo 0)" MACOS_MAJOR="${MACOS_VERSION%%.*}" case "$MACOS_MAJOR" in ''|*[!0-9]*) MACOS_MAJOR=0 ;; esac [ "$MACOS_MAJOR" -ge 13 ] || refuse "Anvilda Listen needs macOS 13 or later; this Mac runs $MACOS_VERSION." case "$LISTEN_PUBKEY" in LISTEN_PUBKEY_PLACEHOLDER|'') refuse "this installer has no release key pinned yet (it is not published); nothing was installed." ;; esac say "Apple Silicon Mac, macOS $MACOS_VERSION" umask 077 WORK="$(mktemp -d "${TMPDIR:-/tmp}/anvilda-listen-install.XXXXXX")" || refuse "could not create a temp directory" [ -n "$WORK" ] && [ -d "$WORK" ] || refuse "could not create a temp directory" trap 'rm -rf "$WORK"' EXIT trap 'exit 130' INT TERM HUP fetch() { # fetch curl -fsSL --proto '=https' --tlsv1.2 --retry 2 --connect-timeout 15 -o "$2" "$1" \ || refuse "could not download $1" } sha256_of() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else sha256sum "$1" | awk '{print $1}'; fi } check_sha() { # check_sha got="$(sha256_of "$1")" [ "$got" = "$2" ] || refuse "$3 failed its SHA-256 check (got $got, pinned $2). Nothing was installed." } # 2. The Node.js runtime. NODE_TGZ="node-v$NODE_VERSION-darwin-arm64.tar.gz" say "Downloading the Node.js $NODE_VERSION runtime" fetch "https://nodejs.org/dist/v$NODE_VERSION/$NODE_TGZ" "$WORK/node.tgz" check_sha "$WORK/node.tgz" "$NODE_SHA256" "the Node.js runtime" mkdir -p "$WORK/runtime" tar -xzf "$WORK/node.tgz" -C "$WORK/runtime" --strip-components 1 || refuse "could not unpack the Node.js runtime" NODE="$WORK/runtime/bin/node" [ -x "$NODE" ] || refuse "the Node.js runtime has no bin/node" say "Node.js runtime verified" # 3. The release: release.json {version, sha256, url, signature}, checked with the runtime above. # The same check as scripts/listen-verify-release.mjs; scripts/listen-install.test.sh runs both on the # same fixtures and requires the same accept/refuse outcome for each. cat > "$WORK/verify.mjs" <<'JS' import { readFileSync } from "node:fs"; import { createHash, createPublicKey, verify } from "node:crypto"; const [bundlePath, releasePath, pub] = process.argv.slice(2); const fail = (m) => { process.stderr.write("REFUSED: " + m + "\n"); process.exit(1); }; let release; try { release = JSON.parse(readFileSync(releasePath, "utf8")); } catch { fail("release.json is not JSON"); } const bundle = readFileSync(bundlePath); if (typeof release.version !== "string" || !/^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/.test(release.version)) fail("no valid version"); if (typeof release.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(release.sha256)) fail("no valid sha256"); if (typeof release.signature !== "string" || !release.signature) fail("no signature"); const raw = Buffer.from(pub, "base64"); if (raw.length !== 32) fail("the pinned public key is not 32 bytes"); const key = createPublicKey({ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), format: "der", type: "spki" }); const digest = createHash("sha256").update(bundle).digest("hex"); if (digest !== release.sha256) fail("sha256 mismatch"); const sig = Buffer.from(release.signature, "base64"); if (sig.length !== 64 || !verify(null, bundle, key, sig)) fail("the Ed25519 signature does not match the pinned key"); process.stdout.write(release.version + "\n"); JS say "Downloading the latest Anvilda Listen release" fetch "$LISTEN_BASE_URL/release.json" "$WORK/release.json" RELEASE_URL="$("$NODE" -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(r.url||""))' "$WORK/release.json")" \ || refuse "release.json is not readable" case "$RELEASE_URL" in "$LISTEN_BASE_URL"/*) ;; *) refuse "release.json points outside $LISTEN_BASE_URL ($RELEASE_URL)" ;; esac fetch "$RELEASE_URL" "$WORK/anvilda-listen.mjs" LISTEN_VERSION="$("$NODE" "$WORK/verify.mjs" "$WORK/anvilda-listen.mjs" "$WORK/release.json" "$LISTEN_PUBKEY")" \ || refuse "the release failed its signature check. Nothing was installed." say "Anvilda Listen $LISTEN_VERSION: signature verified" # 4. The connector. CF_TGZ="cloudflared-darwin-arm64.tgz" say "Downloading cloudflared $CLOUDFLARED_VERSION" fetch "https://github.com/cloudflare/cloudflared/releases/download/$CLOUDFLARED_VERSION/$CF_TGZ" "$WORK/cloudflared.tgz" check_sha "$WORK/cloudflared.tgz" "$CLOUDFLARED_TGZ_SHA256" "cloudflared" mkdir -p "$WORK/cf" tar -xzf "$WORK/cloudflared.tgz" -C "$WORK/cf" || refuse "could not unpack cloudflared" [ -f "$WORK/cf/cloudflared" ] || refuse "the cloudflared download has no cloudflared binary" check_sha "$WORK/cf/cloudflared" "$CLOUDFLARED_BIN_SHA256" "the cloudflared binary" say "cloudflared verified" # What the tool reads (warnings only: the service marks a missing source unavailable, never guesses). if [ -f "$HOME/Library/Application Support/anarlog/app.db" ]; then say "anarlog found" else say "anarlog not found: install it from https://anarlog.com and download its two speech models; calls stay unavailable until then"; fi if [ -d "$HOME/Library/Group Containers/group.net.whatsapp.WhatsApp.shared" ]; then say "WhatsApp Desktop found" else say "WhatsApp Desktop not found (optional): install it from the App Store to share chats"; fi if [ "$DRY_RUN" -eq 1 ]; then say "Dry run: everything downloaded and verified. Nothing was changed. This would:" plan "install the Node.js $NODE_VERSION runtime into $LISTEN_HOME/runtime" plan "install Anvilda Listen $LISTEN_VERSION into $LISTEN_HOME/dist/anvilda-listen.mjs" plan "install cloudflared $CLOUDFLARED_VERSION into $LISTEN_HOME/bin/cloudflared" plan "run anvilda-listen connect (pair this Mac with the Anvilda gateway)" plan "install launchd agent $SERVICE_LABEL ($LAUNCH_AGENTS/$SERVICE_LABEL.plist)" plan "install launchd agent $CONNECTOR_LABEL ($LAUNCH_AGENTS/$CONNECTOR_LABEL.plist)" plan "link $HOME/.local/bin/anvilda-listen and open the live screen" exit 0 fi # 5. Install. Each part is put next to its final place and then moved in, so a failed run never # leaves a half-written runtime or bundle behind. say "Installing into $LISTEN_HOME" mkdir -p "$LISTEN_HOME/dist" "$LISTEN_HOME/bin" "$LISTEN_HOME/state" "$LISTEN_HOME/log" "$LAUNCH_AGENTS" "$HOME/.local/bin" chmod 700 "$LISTEN_HOME" rm -rf "$LISTEN_HOME/runtime.new" mv "$WORK/runtime" "$LISTEN_HOME/runtime.new" rm -rf "$LISTEN_HOME/runtime.old" if [ -d "$LISTEN_HOME/runtime" ]; then mv "$LISTEN_HOME/runtime" "$LISTEN_HOME/runtime.old"; fi mv "$LISTEN_HOME/runtime.new" "$LISTEN_HOME/runtime" rm -rf "$LISTEN_HOME/runtime.old" cp "$WORK/anvilda-listen.mjs" "$LISTEN_HOME/dist/anvilda-listen.mjs.new" mv "$LISTEN_HOME/dist/anvilda-listen.mjs.new" "$LISTEN_HOME/dist/anvilda-listen.mjs" cp "$WORK/cf/cloudflared" "$LISTEN_HOME/bin/cloudflared.new" chmod 755 "$LISTEN_HOME/bin/cloudflared.new" mv "$LISTEN_HOME/bin/cloudflared.new" "$LISTEN_HOME/bin/cloudflared" cat > "$LISTEN_HOME/bin/anvilda-listen" < "$LISTEN_HOME/bin/run-cloudflared.sh" < "\$d/token" 2>/dev/null; then echo "anvilda-listen: no tunnel token in the Keychain; run: anvilda-listen connect" >&2 exit 1 fi "$LISTEN_HOME/bin/cloudflared" tunnel --no-autoupdate run --token-file "\$d/token" & pid=\$! trap 'kill "\$pid" 2>/dev/null; rm -rf "\$d"; exit 0' INT TERM wait "\$pid" EOF chmod 755 "$LISTEN_HOME/bin/run-cloudflared.sh" ln -sf "$LISTEN_HOME/bin/anvilda-listen" "$HOME/.local/bin/anvilda-listen" # Pairing: shows a short code and a link; the owner confirms it in the browser. TTY_IN=/dev/null if [ -r /dev/tty ] && (: < /dev/tty) 2>/dev/null; then TTY_IN=/dev/tty; fi say "Connecting this Mac to the Anvilda gateway" "$LISTEN_HOME/bin/anvilda-listen" connect < "$TTY_IN" || refuse "pairing did not finish; run: anvilda-listen connect, then this installer again" xml_escape() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g'; } write_plist() { # write_plist