From 18d52c312d14519fb31eae5507b942387c071c23 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:11 +0900 Subject: [PATCH] chore(personal): signed desktop releases for every Mac, plus a runbook Local desktop builds were ad-hoc signed, so every new build was a new app to macOS privacy grants, keychain ACLs and Little Snitch, and each Mac re-prompted for everything. Installs were also hand-rolled per Mac, and matebook lost its app to one of them. scripts/personal/ now holds the whole desktop flow: - t3-alpha-build: aligns package versions (so the server reports the release version too), builds the zip, re-signs it with a stable identity - t3-alpha-install: the installer, moved from ~/.local/bin; refuses unsigned zips, checks the bundle version, prunes old backups - t3-alpha-deploy: installs a zip on every Mac over SSH, studio last release-pipeline.md becomes the single runbook: machines, upstream sync, desktop deploy, mobile build/submit, one-time setup. --- docs/personal/release-pipeline.md | 182 ++++++++++--------- scripts/personal/t3-alpha-build | 62 +++++++ scripts/personal/t3-alpha-deploy | 57 ++++++ scripts/personal/t3-alpha-install | 293 ++++++++++++++++++++++++++++++ 4 files changed, 509 insertions(+), 85 deletions(-) create mode 100755 scripts/personal/t3-alpha-build create mode 100755 scripts/personal/t3-alpha-deploy create mode 100755 scripts/personal/t3-alpha-install diff --git a/docs/personal/release-pipeline.md b/docs/personal/release-pipeline.md index d65e53d89239..52df0b08335f 100644 --- a/docs/personal/release-pipeline.md +++ b/docs/personal/release-pipeline.md @@ -1,94 +1,106 @@ -# Release pipeline: personal T3 Code mobile builds - -> Ported 2026-07-25 from the (archived) planning repo [r4iju/t3-code](https://github.com/r4iju/t3-code); -> this copy is now the living runbook. Historical decision links below point at that repo's issues. - -Spec for [issue #6](https://github.com/r4iju/t3-code/issues/6). Decisions settled on the -[wayfinder map](https://github.com/r4iju/t3-code/issues/1) on 2026-07-23: v1 is upstream's -`apps/mobile` as-is (#5), fork delta stays env/config-only with ad-hoc `upstream/main` merges (#8), -distribution is personal/internal only, connectivity is direct LAN only. - -## Principles - -- **The fork ships upstream's app; we ship the pipeline.** No feature delta, so the pipeline is the - product of this effort. -- **Env/config-only delta.** Pipeline config lives in EAS environment variables, additive files, and - this repo's docs — upstream files are edited only when unavoidable (e.g. if `app.config.ts` lacks - an override hook for bundle identity). -- **decent-measure is the style reference, not a template to copy.** Where its choices fight the - env/config-only rule (local credentials + fastlane), we deviate deliberately. - -## Identity - -- Own bundle/application ID per variant, own EAS project, own App Store Connect record — upstream's - `com.t3tools.t3code.*` IDs stay untouched for clean upstream merges: - - iOS/Android production: `com.raijustudios.t3code` - - Development variant keeps upstream's dev ID locally (dev builds are never distributed). -- Apple team `C7X9BCC7LP` (same as decent-measure). App name on TestFlight: "T3 Code (personal)". -- Prefer setting identity via EAS env vars / `APP_VARIANT` hooks if upstream's `app.config.ts` - supports it; otherwise a minimal, well-marked edit in `app.config.ts` (allowed "when unavoidable"). - -## Build & distribute - -| Platform | Profile | Output | Distribution | -| -------- | --------------------- | ------ | ----------------------------------------------- | -| iOS | upstream `production` | .ipa | `eas submit` → **TestFlight internal** | -| Android | upstream `production` | .aab | `eas submit` → **Google Play internal testing** | - -> **Decision change (2026-07-23):** Android originally shipped as a preview APK via EAS internal -> link. Changed to match decent-measure: production AAB submitted to the Play internal testing -> track with the same Play service account (`barbellry-…json`). Requires a Play Console app record -> for `com.raijustudios.t3code` (manual — Play has no app-creation API) and the fork's -> `T3CODE_ANDROID_PACKAGE` env hook (upstream has no Android identity override). - -- **EAS cloud is the default builder**; the free tier covers occasional personal builds. - Local `eas build --local` is the documented fallback (needs Xcode 26.1+ — ticket #9 — and - JDK 17 + `ANDROID_HOME`; see friction log on - [issue #3](https://github.com/r4iju/t3-code/issues/3)). -- **Credentials: EAS-managed (remote)** for both platforms. This deviates from decent-measure's - local-credentials + fastlane setup on purpose — remote credentials mean zero credential files in - the fork, consistent with env/config-only. Submit credentials (ASC API key for iOS, Play service - account key for Android, both shared with decent-measure) live in the gitignored - `apps/mobile/credentials/` and are referenced by the `personal` submit profile — nothing secret - is committed. -- **Versioning:** follow upstream's app version (their `appVersion` runtime policy); build numbers - auto-increment via EAS remote version source. -- **T3 Connect env vars stay unset.** LAN-only scope; cloud UI stays disabled in our builds. - -## Release ritual +# Release runbook: personal T3 Code + +How this fork ships: sync upstream, build and deploy the desktop app to every Mac, build and +submit the mobile apps. Originally ported 2026-07-25 from the archived planning repo +[r4iju/t3-code](https://github.com/r4iju/t3-code); historical decision links point there. + +**The fork ships upstream's app; we ship the pipeline.** The delta stays env/config-only plus +additive files (`docs/personal/`, `scripts/personal/`). Upstream files are edited only when +unavoidable. Before each release, review the exit-path register in +[contributing-upstream.md](./contributing-upstream.md): every feature delta must be moving +toward upstream or have a written reason to stay. + +## Machines + +| Machine | SSH | Role | +| ------------------------ | ------------------------ | -------------------------------------------------- | +| studio (Mac Studio) | — | Builds and signs releases; T3 Code host | +| matebook | `emanuel@matebook.lan` | T3 Code host | +| sm-em (work MacBook Pro) | `emanuelfranzen@Mac.lan` | T3 Code host | +| iPhone / Android | — | Mobile clients: TestFlight / Play internal testing | + +Every Mac runs "T3 Code (Alpha)" from `/Applications`, built from this fork. A LAN server is +the desktop app with Settings → Connections → Network access on (`0.0.0.0:3773`, pairing QR). +Pairing tokens expire in ~5 minutes; mint one right before pairing (`t3 auth pairing create`). + +## 1. Sync upstream + +Direct pushes to `main` are blocked, so a sync is a PR: ```bash -cd ~/code/t3code -git fetch upstream && git merge upstream/main # ad-hoc sync (#8) -cd apps/mobile -# T3CODE_EAS_OWNER / T3CODE_EAS_PROJECT_ID / T3CODE_ANDROID_PACKAGE env vars required locally -vp run eas:ios:prod # → .ipa -vp run eas:android:prod # → .aab -eas submit -p ios --latest --profile personal # → TestFlight internal -eas submit -p android --latest --profile personal # → Play internal testing +git -C ~/code/t3code fetch upstream +git -C ~/code/t3code worktree add ~/code/t3code-wt-sync -b sync/upstream-$(date +%Y%m%d) origin/main +cd ~/code/t3code-wt-sync && git merge upstream/main && vp i +git push -u origin HEAD && gh pr create --fill && gh pr merge --merge +git -C ~/code/t3code pull --ff-only +git -C ~/code/t3code worktree remove ~/code/t3code-wt-sync ``` -No CI initially — releases are manual and occasional. If cadence grows, lift upstream's -fingerprint-based EAS workflow (`.github/workflows/mobile-eas-*.yml`) into the fork. +`gh` resolves to `origin` (`remote.origin.gh-resolved base`); upstream PRs need an explicit +`--repo pingdotgg/t3code`. + +## 2. Desktop: build, sign, deploy -Before each release, review the exit-path register in -[contributing-upstream.md](./contributing-upstream.md) — every feature delta on this fork -must be moving toward upstream or have a written reason to stay. +On studio, from a clean `~/code/t3code` on `main`: -## Server operations (decided with the map) +```bash +scripts/personal/t3-alpha-build 0.0.47 +scripts/personal/t3-alpha-deploy release/T3-Code-0.0.47-arm64.zip +``` + +- **Version:** the next patch after the last deploy. Every package.json is set to it for the + build and restored afterwards, so the app and its server report the same version. +- **Signing:** the build is signed with the Apple Development certificate in studio's keychain. + macOS privacy grants (Screen Recording, Accessibility, …), keychain access and Little Snitch + rules are tied to that signature, so they carry over between builds. An unsigned build is a + new app to all of them; the installer refuses one. +- **Deploy:** copies the zip and installer to matebook and sm-em over SSH, installs, and waits + for each result. Studio goes last because its restart ends any T3 session driving the + deploy. Name hosts to deploy to a subset: `… .zip emanuel@matebook.lan local`. +- **Target Macs must be logged in:** the installer launches the app in the GUI session. +- **Failures roll back** to the previous bundle automatically. Each Mac logs to + `~/Library/Logs/t3-alpha-install.log`; `scripts/personal/t3-alpha-install --status` shows + what runs, `--restart` relaunches. Only the latest `.bak-*` bundle is kept. + +## 3. Mobile: build and submit + +Identity is the fork's own (`com.raijustudios.t3code`, Apple team `C7X9BCC7LP`, EAS project +`@expomozdom/t3-code`); upstream's `com.t3tools.t3code.*` IDs stay untouched. The identity env +vars live in EAS (production and preview): + +``` +T3CODE_EAS_OWNER=expomozdom +T3CODE_EAS_PROJECT_ID=041ec0cd-429a-40d9-8d00-9fcf196ebb59 +T3CODE_ANDROID_PACKAGE=com.raijustudios.t3code +T3CODE_IOS_PERSONAL_TEAM=1 +T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.raijustudios.t3code +``` -The LAN server is the **desktop app with Settings → Connections → Network access toggled on** -(binds `0.0.0.0:3773`, stable port, built-in pairing QR). No launchd service, no headless `t3 serve` -for daily use. Device pairing management: `t3 auth` (`pairing create`, `session list/revoke`). -Pairing tokens expire in ~5 minutes — mint right before pairing a new device. +From `apps/mobile`, logged in to EAS as `expomozdom`: -## Implementation checklist (post-map execution) +```bash +eas build --profile production -p ios --non-interactive --no-wait +eas build --profile production -p android --non-interactive --no-wait +eas submit -p ios --latest --profile personal # → TestFlight internal +eas submit -p android --latest --profile personal # → Play internal testing +``` -1. Create the App Store Connect app record for `com.raijustudios.t3code`. -2. `eas init` in the fork's `apps/mobile` against a personal EAS project; set EAS env vars/secrets - (ASC API key; identity overrides if the env hook exists). -3. First iOS `production` build + submit; install from TestFlight. -4. Create the Play Console app record for `com.raijustudios.t3code` (manual); first Android - `production` AAB build + submit to the internal testing track; install from Play. -5. Pair both against the desktop app's network endpoint (`http://:3773`). -6. Verify local-build fallback once Xcode 26.1+ lands (#9). +- EAS cloud builds by default; `eas build --local` is the fallback (Xcode 26.1+, JDK 17 + + `ANDROID_HOME`). +- Credentials are EAS-managed. Submit credentials (ASC API key, Play service account) live in + the gitignored `apps/mobile/credentials/`. +- Version follows upstream's app version; build numbers auto-increment remotely. +- Do not dispatch `.github/workflows/mobile-eas-production.yml` on the fork: it has no + `EXPO_TOKEN` secret and silently no-ops. +- T3 Connect env vars stay unset: LAN-only scope. + +## One-time setup + +- **Signing certificate** (studio): "Apple Development: Emanuel Franzen (T4J48V44Q2)" in the + login keychain; allow `codesign` access once. A renewed certificate keeps its name, so grants + survive renewal. Signing with another identity (`T3_ALPHA_SIGN_IDENTITY`, for example a + Developer ID) makes every Mac re-grant permissions once. To build on another Mac, export the + certificate with its key as `.p12` and import it there. +- **First signed install on a Mac:** grant macOS permissions and Little Snitch rules once more; + they stick from then on. +- **SSH:** studio needs key-based SSH to every deploy target. diff --git a/scripts/personal/t3-alpha-build b/scripts/personal/t3-alpha-build new file mode 100755 index 000000000000..e91227f0f66b --- /dev/null +++ b/scripts/personal/t3-alpha-build @@ -0,0 +1,62 @@ +#!/bin/bash +# t3-alpha-build — build a signed "T3 Code (Alpha)" zip (macOS arm64) from the +# checkout this script lives in. Runs on the Mac that holds the signing +# certificate; every other Mac only installs the result (t3-alpha-deploy). +# +# Mirrors upstream's release job: align every package.json to the release +# version so the server reports it too, build, then restore the manifests. +# +# The zip is then re-signed in place with a stable identity. An ad-hoc build's +# designated requirement is its own cdhash, so TCC grants, keychain ACLs and +# Little Snitch rules never carried over to the next build. +# +# Usage: t3-alpha-build # -> release/T3-Code--arm64.zip + +set -euo pipefail + +VERSION="${1:?usage: t3-alpha-build }" +SIGN_IDENTITY="${T3_ALPHA_SIGN_IDENTITY:-Apple Development: Emanuel Franzen (T4J48V44Q2)}" +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +ZIP="$REPO/release/T3-Code-$VERSION-arm64.zip" +cd "$REPO" +# The artifact build spawns `vp`, which only exists in the repo's node_modules. +export PATH="$REPO/node_modules/.bin:$PATH" +unset ELECTRON_RUN_AS_NODE + +if ! git diff --quiet HEAD; then + echo "error: $REPO has uncommitted changes; build from a clean checkout." >&2 + exit 1 +fi + +STAGE="$(mktemp -d /tmp/t3-alpha-build.XXXXXX)" +# The manifests were clean above, so any package.json diff is the version bump. +trap 'rm -rf "$STAGE"; git -C "$REPO" diff --name-only -z -- "*package.json" | xargs -0 git -C "$REPO" checkout --' EXIT + +vp i +node scripts/update-release-package-versions.ts "$VERSION" +node scripts/build-desktop-artifact.ts --platform mac --target zip --arch arm64 --build-version "$VERSION" + +# Inside-out: every Mach-O file (native modules and helpers are not all +x), +# then nested bundles deepest first, then the app itself. The build has no +# hardened runtime or entitlements to carry over, but preserve them if it ever does. +sign_app() { # + local app="$1" f + local opts=(--force --timestamp=none --preserve-metadata=identifier,entitlements --sign "$SIGN_IDENTITY") + while IFS= read -r f; do + codesign "${opts[@]}" "$f" + done < <(find "$app/Contents" -type f -print0 | xargs -0 file | awk -F': ' '/Mach-O/ { print $1 }') + while IFS= read -r f; do + codesign "${opts[@]}" "$f" + done < <(find "$app/Contents" -type d \( -name '*.framework' -o -name '*.app' \) | awk '{ print gsub("/", "/") "\t" $0 }' | sort -rn | cut -f2-) + codesign "${opts[@]}" "$app" + codesign --verify --deep --strict "$app" +} + +ditto -x -k "$ZIP" "$STAGE" +APP="$(find "$STAGE" -maxdepth 1 -name '*.app' | head -1)" +sign_app "$APP" 2> >(grep -v 'replacing existing signature' >&2) +rm -f "$ZIP" "$ZIP.blockmap" +ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + +echo "Built and signed $ZIP" +codesign -d -r- "$APP" 2>&1 | tail -1 diff --git a/scripts/personal/t3-alpha-deploy b/scripts/personal/t3-alpha-deploy new file mode 100755 index 000000000000..53ed7e16ee28 --- /dev/null +++ b/scripts/personal/t3-alpha-deploy @@ -0,0 +1,57 @@ +#!/bin/bash +# t3-alpha-deploy — install a zip from t3-alpha-build on every Mac. Remote hosts +# go first over SSH, `local` last: installing here restarts the T3 Code this +# may be running inside, which ends the session driving the deploy. +# +# Each host gets this checkout's t3-alpha-install copied next to the zip, so +# no Mac carries its own installer copy that can drift. +# +# Usage: t3-alpha-deploy [host ...] +# hosts default to: emanuel@matebook.lan emanuelfranzen@Mac.lan local + +set -uo pipefail + +ZIP="${1:?usage: t3-alpha-deploy [host ...]}" +shift +HOSTS=("$@") +[[ ${#HOSTS[@]} -eq 0 ]] && HOSTS=(emanuel@matebook.lan emanuelfranzen@Mac.lan local) +INSTALLER="$(cd "$(dirname "$0")" && pwd)/t3-alpha-install" +REMOTE_LOG='~/Library/Logs/t3-alpha-install.log' +INFO_PLIST="$(unzip -Z1 "$ZIP" | grep -E '^[^/]+\.app/Contents/Info\.plist$')" +VERSION="$(unzip -p "$ZIP" "$INFO_PLIST" | plutil -extract CFBundleShortVersionString raw -)" +[[ -n "$VERSION" ]] || { echo "error: cannot read the app version from $ZIP" >&2; exit 1; } + +deploy_remote() { # + local host="$1" zip_name before pid result + zip_name="$(basename "$ZIP")" + echo "== $host: copying $zip_name" + scp -q "$ZIP" "$INSTALLER" "$host:/tmp/" || return 1 + before=$(ssh "$host" "cat $REMOTE_LOG 2>/dev/null | wc -l" | tr -d ' ') + pid=$(ssh "$host" "/bin/bash /tmp/t3-alpha-install /tmp/$zip_name --expect-version $VERSION /dev/null"; do sleep 3; done + ssh "$host" "rm -f /tmp/$zip_name /tmp/t3-alpha-install" + result=$(ssh "$host" "tail -n +$((before + 1)) $REMOTE_LOG") + sed 's/^/ /' <<<"$result" + grep -q "SUCCESS:" <<<"$result" +} + +failed=() +for host in "${HOSTS[@]}"; do + [[ "$host" == local ]] && continue + deploy_remote "$host" || failed+=("$host") +done + +if [[ ${#failed[@]} -gt 0 ]]; then + echo "Failed on: ${failed[*]}. Skipping this Mac." >&2 + exit 1 +fi + +for host in "${HOSTS[@]}"; do + if [[ "$host" == local ]]; then + echo "== local: installing (T3 Code here restarts; follow ~/Library/Logs/t3-alpha-install.log)" + "$INSTALLER" "$ZIP" --expect-version "$VERSION" + fi +done diff --git a/scripts/personal/t3-alpha-install b/scripts/personal/t3-alpha-install new file mode 100755 index 000000000000..ebdb8dc8e61d --- /dev/null +++ b/scripts/personal/t3-alpha-install @@ -0,0 +1,293 @@ +#!/bin/bash +# t3-alpha-install — install a locally built "T3 Code (Alpha)" zip and relaunch +# it, from a terminal that may live INSIDE the app being replaced (a T3 agent +# session, T3 terminal, SSH, anywhere). +# +# Sibling of t3-self-update (Homebrew nightly). Same two-phase shape: +# Phase 1 (foreground): re-exec detached via nohup and return immediately, so +# the app dying mid-install does not kill the installer. +# Phase 2 (detached): graceful quit -> wait for LaunchServices to forget the +# app -> backup old bundle -> ditto-extract the zip (no quarantine) -> strip +# quarantine BEFORE first exec -> unstick recipe -> launch (with fallbacks) +# -> verify a process exists, :3773 listens, and the RUNNING instance reports +# the expected version. On failure, roll back to the backup and relaunch it. +# +# Two known ways a relaunch silently fails on this Mac: +# 1. `open -a` right after the old instance quits: LaunchServices still has +# the old ASN registered, so `open` just sends "activate" to a dying app, +# returns 0, and nothing launches (2026-09-12). Hence wait_for_ls_forget. +# 2. First exec of a freshly installed *quarantined* bundle can wedge a +# per-path launch state at _dyld_start; every later launch freezes. +# Stripping quarantine pre-exec avoids poisoning it; the fresh-inode + +# rename "unstick" recipe clears it if it happens anyway (t3-self-update). +# 3. `open -a` hands the caller's environment to the launched app. A shell +# inside a T3 agent session carries ELECTRON_RUN_AS_NODE=1 (the server +# runs under Electron-as-node), so the relaunched app came up as a bare +# Node REPL, read EOF on stdin, and exited after ~1.6s with no window and +# no server (2026-09-12, 0.0.43). Hence the env scrub below. +# 4. Not a failure, but looks like one: an app launched by a detached script +# is denied the right to come to front ("not in the list of +# permittedFrontASNs"), so it runs behind every other window. bring_front +# re-activates it once the server is up and posts a notification either way. +# +# Only zips signed by t3-alpha-build are installed. An ad-hoc build's designated +# requirement is the cdhash of that one build, and TCC grants (Screen Recording, +# Accessibility, ...), keychain ACLs and Little Snitch rules key on it, so every +# unsigned build re-prompted for everything on every Mac. +# +# After a successful install only the backup just taken is kept; older +# .bak-*/.failed-* bundles are removed. +# +# Usage: +# t3-alpha-install -arm64.zip> [--expect-version X] +# t3-alpha-install --restart [--expect-version X] # quit + relaunch only +# t3-alpha-install --status # what is running right now +# --expect-version fail (and roll back if installing) unless the running +# instance reports version X + +set -uo pipefail + +APP="/Applications/T3 Code (Alpha).app" +APP_NAME="T3 Code (Alpha)" +APP_BIN="$APP/Contents/MacOS/$APP_NAME" +LOG="$HOME/Library/Logs/t3-alpha-install.log" +PORT=3773 +SIGNED_REQUIREMENT='identifier "com.t3tools.t3code" and anchor apple generic and certificate leaf[subject.OU] = "C7X9BCC7LP"' +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" +# Never let the relaunched app inherit the Electron/Node runtime knobs of the +# instance we are running inside of (see known failure 3). +unset ELECTRON_RUN_AS_NODE ELECTRON_NO_ATTACH_CONSOLE ELECTRON_ENABLE_LOGGING NODE_OPTIONS NODE_CHANNEL_FD + +if [[ "${1:-}" == "--status" ]]; then + main_pid=$(ps ax -o pid= -o command= | grep -F "$APP_BIN" | grep -v -e grep -e Helper -e bin.mjs | awk '{print $1}' | head -1) + bundle_ver=$(defaults read "$APP/Contents/Info" CFBundleShortVersionString 2>/dev/null) + if [[ -z "$main_pid" ]]; then + echo "not running (bundle on disk: ${bundle_ver:-none})"; exit 1 + fi + listener=$(lsof -nP -iTCP:$PORT -sTCP:LISTEN -t 2>/dev/null | head -1) + if [[ -n "$listener" ]]; then port_state="listening (pid $listener)"; else port_state="NOT listening"; fi + echo "running: pid $main_pid, started $(ps -o lstart= -p "$main_pid" | xargs), bundle $bundle_ver, :$PORT $port_state" + echo "last install log: $(grep -E 'SUCCESS|ERROR|ROLLED BACK' "$LOG" 2>/dev/null | tail -1)" + exit 0 +fi + +if [[ "${1:-}" != "--detached" ]]; then + MODE="${1:-}" + if [[ "$MODE" == "--restart" ]]; then + ZIP="" + elif [[ -f "$MODE" ]]; then + ZIP="$(cd "$(dirname "$MODE")" && pwd)/$(basename "$MODE")" + else + echo "usage: t3-alpha-install |--restart [--expect-version X]" >&2 + exit 2 + fi + nohup /bin/bash "$0" --detached "$ZIP" "${2:-}" "${3:-}" >> "$LOG" 2>&1 & + disown + echo "Started in background (pid $!). Watch: tail -f $LOG" + echo "The app will restart; this session will die with it if it runs inside T3." + exit 0 +fi + +ZIP="$2" +EXPECT_VERSION="" +[[ "${3:-}" == "--expect-version" ]] && EXPECT_VERSION="${4:-}" +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*"; } + +# Only processes whose executable lives inside the Alpha bundle: the Electron +# main, its helpers, and the bundled server. Never a worktree path. +app_pids() { ps ax -o pid= -o command= | grep -F "$APP/Contents/" | grep -v grep | awk '{print $1}'; } + +# LaunchServices' view, which lags the process table by a few seconds. +ls_registered() { lsappinfo info -app "$APP_NAME" 2>/dev/null | grep -q "ASN:"; } +# The bundle's version, not lsappinfo's (lags one install behind) or the +# server's (apps/server/package.json, which local builds do not bump). Both +# rolled back a healthy 0.0.45 as "0.0.42" (2026-09-24). stop_app waits for +# every old pid to exit, so whatever runs from $APP afterwards is this bundle. +running_version() { defaults read "$APP/Contents/Info" CFBundleShortVersionString 2>/dev/null; } + +server_up() { lsof -nP -iTCP:$PORT -sTCP:LISTEN >/dev/null 2>&1; } + +wait_for() { # + local n="$1" fn="$2" + for _ in $(seq 1 "$n"); do + if "$fn"; then return 0; fi + sleep 1 + done + return 1 +} +have_pids() { [[ -n "$(app_pids)" ]]; } +no_pids() { [[ -z "$(app_pids)" ]]; } +ls_forgot() { ! ls_registered; } + +stop_app() { + local pids + pids=$(app_pids) + if [[ -z "$pids" ]]; then + log "No running instance." + else + log "Asking $APP_NAME to quit (pids: $(echo $pids))" + osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 + if wait_for 20 no_pids; then + log "Quit cleanly." + else + pids=$(app_pids) + log "Still running after 20s; killing: $(echo $pids)" + kill $pids 2>/dev/null + sleep 3 + pids=$(app_pids) + [[ -n "$pids" ]] && kill -9 $pids 2>/dev/null + sleep 1 + fi + fi + # Processes are gone, but `open -a` against an ASN LaunchServices still + # holds just "activates" the dead instance and launches nothing. + if wait_for 30 ls_forgot; then + log "LaunchServices released the app." + else + log "WARN: LaunchServices still lists $APP_NAME after 30s; continuing." + fi + sleep 2 +} + +launch_app() { + open -a "$APP" + log "open -a exit=$?" + if wait_for 15 have_pids; then return 0; fi + log "No process after open -a; retrying with open -n." + open -n -a "$APP" + log "open -n exit=$?" + if wait_for 15 have_pids; then return 0; fi + log "No process after open -n; exec'ing the binary directly." + nohup "$APP_BIN" >/dev/null 2>&1 & + disown + if wait_for 15 have_pids; then return 0; fi + log "ERROR: no $APP_NAME process after three launch attempts." + return 1 +} + +launch_and_verify() { + # 1) fresh inode for the main binary 2) rename bundle away and back. + cp "$APP_BIN" "$APP_BIN.new" && rm "$APP_BIN" && mv "$APP_BIN.new" "$APP_BIN" && chmod +x "$APP_BIN" + mv "$APP" "${APP%.app}.updating.app" && mv "${APP%.app}.updating.app" "$APP" + log "Unstick recipe applied." + launch_app || return 1 + log "Process up (pids: $(echo $(app_pids))). Waiting for :$PORT ..." + local waited=0 + while ! server_up; do + if no_pids; then + log "ERROR: $APP_NAME exited after ${waited}s without listening on :$PORT." + return 1 + fi + if (( waited >= 120 )); then + log "ERROR: server not listening on :$PORT after 120s." + local pid + pid=$(app_pids | head -1) + [[ -n "$pid" ]] && log "Process $pid state: $(vmmap "$pid" 2>&1 | head -1)" + return 1 + fi + sleep 1; waited=$((waited + 1)) + done + local ver + ver=$(running_version) + log "Server up on :$PORT, running version $ver ($(lsappinfo info -app "$APP_NAME" 2>/dev/null | sed -nE 's/.*parentASN="([^"]+)".*/launched by \1/p' | head -1))" + if [[ -n "$EXPECT_VERSION" && "$ver" != "$EXPECT_VERSION" ]]; then + log "ERROR: expected version $EXPECT_VERSION, got $ver" + return 1 + fi + return 0 +} + +front_app() { lsappinfo info -only name "$(lsappinfo front 2>/dev/null)" 2>/dev/null | sed -nE 's/.*"LSDisplayName"="([^"]+)".*/\1/p'; } + +# Launched from a background script the app cannot take focus itself; ask +# LaunchServices and the app to activate, then tell the user where things +# stand so a hidden window is not mistaken for a failed launch. +bring_front() { + open -a "$APP" 2>/dev/null + osascript -e "tell application \"$APP_NAME\" to activate" >/dev/null 2>&1 + sleep 1 + log "Frontmost app now: $(front_app)" +} +notify() { osascript -e "display notification \"$1\" with title \"t3-alpha-install\"" >/dev/null 2>&1; } + +prune_old_bundles() { # + local b + for b in "${APP%.app}".bak-*.app "${APP%.app}".failed-*.app; do + [[ -d "$b" && "$b" != "$1" ]] || continue + rm -rf "$b" && log "Removed old bundle $b" + done +} + +if [[ -z "$ZIP" ]]; then + log "=== t3-alpha-install --restart ===" + stop_app + if launch_and_verify; then + log "SUCCESS: $APP_NAME restarted." + bring_front; notify "$APP_NAME restarted and is up on :$PORT." + exit 0 + fi + log "ERROR: restart failed; bundle left in place at $APP." + notify "$APP_NAME restart FAILED. See $LOG" + exit 1 +fi + +log "=== t3-alpha-install starting: $ZIP ===" + +STAGE="$(mktemp -d /tmp/t3-alpha-install.XXXXXX)" +trap 'rm -rf "$STAGE"' EXIT +# ditto does not apply com.apple.quarantine (Archive Utility does). +if ! ditto -x -k "$ZIP" "$STAGE"; then + log "ERROR: failed to extract $ZIP" + exit 1 +fi +NEW_APP="$(find "$STAGE" -maxdepth 1 -name '*.app' | head -1)" +if [[ -z "$NEW_APP" ]]; then + log "ERROR: no .app inside $ZIP" + exit 1 +fi +NEW_VER=$(defaults read "$NEW_APP/Contents/Info" CFBundleShortVersionString 2>/dev/null) +log "Extracted $(basename "$NEW_APP") version $NEW_VER" +xattr -dr com.apple.quarantine "$NEW_APP" 2>/dev/null +if ! codesign --verify --deep --strict -R="$SIGNED_REQUIREMENT" "$NEW_APP"; then + log "ERROR: $(basename "$ZIP") is not signed by t3-alpha-build; nothing installed." + notify "$APP_NAME $NEW_VER is unsigned; nothing installed. See $LOG" + exit 1 +fi + +stop_app + +BACKUP="" +if [[ -d "$APP" ]]; then + BACKUP="${APP%.app}.bak-$(date +%Y%m%d-%H%M%S).app" + mv "$APP" "$BACKUP" + log "Backed up old bundle to $BACKUP" +fi +mv "$NEW_APP" "$APP" +xattr -dr com.apple.quarantine "$APP" 2>/dev/null +log "Installed new bundle; quarantine stripped." + +if launch_and_verify; then + log "SUCCESS: $APP_NAME $NEW_VER is up." + prune_old_bundles "$BACKUP" + bring_front; notify "$APP_NAME $NEW_VER installed and up on :$PORT." + exit 0 +fi + +log "Rolling back." +notify "$APP_NAME $NEW_VER failed to start; rolling back. See $LOG" +stop_app +FAILED="${APP%.app}.failed-$(date +%Y%m%d-%H%M%S).app" +mv "$APP" "$FAILED" 2>/dev/null && log "Kept failed bundle at $FAILED" +if [[ -n "$BACKUP" && -d "$BACKUP" ]]; then + mv "$BACKUP" "$APP" + EXPECT_VERSION="" # the backup is the previous version by definition + if launch_and_verify; then + log "ROLLED BACK: previous $APP_NAME is up again." + bring_front + else + log "ERROR: rollback launch failed too. Bundle is at $APP." + fi +else + log "ERROR: no backup to roll back to." +fi +exit 1