feat(@applications/tv-anarchy): improve macOS install logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 20:50:54 -07:00
parent b44b5a2d1a
commit 913135ca8c
6 changed files with 158 additions and 23 deletions

View file

@ -1,17 +1,34 @@
#!/usr/bin/env bash
# Build TVAnarchy (Release) and install it to ~/Applications — the per-user
# install location (no root/sudo, still shown in Finder's "Applications"), so the
# running app and the built app can't silently drift. The one irreducible manual
# step is quitting + relaunching — you can't hot-swap a running native app — and
# the sidebar build stamp (v<ver> · <sha> · <time>) makes a stale copy obvious.
# Build TVAnarchy (Release) and install it to the OS-appropriate Applications
# folder (see resolve_dest), so the running app and the built app can't silently
# drift. The one irreducible manual step is quitting + relaunching — you can't
# hot-swap a running native app — and the sidebar build stamp
# (v<ver> · <sha> · <time>) makes a stale copy obvious.
set -euo pipefail
cd "$(dirname "$0")"
# Where TVAnarchy.app installs. Duplicated in tools/update.sh (kept in sync) so
# that script stays curl-able standalone:
# TVANARCHY_DEST env → explicit override, used verbatim
# macOS, /Applications writable → /Applications (the standard location —
# Finder's "Applications", admin group, no sudo)
# macOS, not writable (non-admin)→ ~/Applications (Apple's per-user location)
# anything else → fail loud; the .app bundle is macOS-only
resolve_dest() {
if [ -n "${TVANARCHY_DEST:-}" ]; then printf '%s\n' "$TVANARCHY_DEST"; return; fi
[ "$(uname -s)" = "Darwin" ] || { echo "✗ TVAnarchy.app is macOS-only (this is $(uname -s))." >&2; return 1; }
if [ -w /Applications ]; then
printf '/Applications/TVAnarchy.app\n'
else
printf '%s/Applications/TVAnarchy.app\n' "$HOME"
fi
}
# Default DD lives in the repo (build/dd); tools/release.sh overrides it with an
# ephemeral tmp dir so a release cut never churns the working build cache.
DD="${TVANARCHY_DD:-build/dd}"
APP="$DD/Build/Products/Release/TVAnarchy.app"
DEST="$HOME/Applications/TVAnarchy.app"
DEST="$(resolve_dest)"
echo "→ stamp build identity (git SHA / time → BuildStamp.swift)"
tools/stamp-build.sh
@ -29,6 +46,17 @@ mkdir -p "$(dirname "$DEST")"
rm -rf "$DEST"
cp -R "$APP" "$DEST"
# One install location only: drop a stale copy at the other auto candidate so
# the launched app can never silently be an old build. Skipped under an explicit
# TVANARCHY_DEST (e.g. a test install must not touch the real one).
if [ -z "${TVANARCHY_DEST:-}" ]; then
for other in "/Applications/TVAnarchy.app" "$HOME/Applications/TVAnarchy.app"; do
if [ "$other" != "$DEST" ] && [ -d "$other" ]; then
rm -rf "$other" && echo " removed stale copy at $other"
fi
done
fi
# Marketing version is in the plist; SHA / build / time live in the compiled
# BuildStamp constant we just generated (the reliable source — see project.yml).
VER=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$DEST/Contents/Info.plist")

View file

@ -59,7 +59,7 @@ A single protocol (poll / playPause / resume / setVolume / seek / next / previou
### `PlayerController`
`@MainActor`/Observable. Owns target CRUD (persisted to `hosts.json`), a
`@MainActor`/Observable. Owns target CRUD (persisted to `devices.json`), a
single-flight poll loop (cadence varies by tab visibility), optimistic command
routing, quality/release switching (refetch only on episode boundary), audio/sub
track selection (persisted per series), a sleep-timer state machine, a transfer
@ -83,6 +83,10 @@ Cached-first: load a snapshot instantly, refresh in the background, persist.
to drive the Continue-Watching rail and resume positions.
- **`LibraryController`** — Home rails (Continue / Recently-Added / per-category),
franchise prefix-matching, scan orchestration, launch-request building.
- **`ShowGrouping` / `LocalLLMGrouper` / `ContentID`** — groups episodes into
shows (with an optional local-LLM pass for messy names) keyed by stable
content IDs; **`LibraryConfig`** makes the folder→type mapping user-editable
(types can be added/renamed/removed in Setup; each carries an `adult` flag).
### Downloads pipeline (`Torrents/`)
@ -98,10 +102,56 @@ completion callback so finished folders get incrementally indexed.
`ArtworkService` (ffmpeg frame-grab fallback). An MLX `TitleRefiner` seam exists
but is unwired (see [roadmap.md](./roadmap.md)).
### Device registry (Devices tab, `DeviceConfig`)
Playback targets generalized into **devices**: each entry in `devices.json` has a
user-facing `type` (cellphone / laptop / storage / seedbox / broadcast-station)
that maps onto the planned fleet host classes (consumer / roamer / server /
seedbox / broadcast) and presets an overridable `services` set (stream,
offline-cache, TTL-seed, custody, public-swarm-face, F2F-relay, mesh-anchor).
The Devices list shows a per-device system-load badge (low/med/high, via
`HostStats`). This is the **app-side registry** for mesh stage 1; the duty
*assignment engine* (governor-side) is still unbuilt — see
[roadmap.md](./roadmap.md). Legacy `hosts.json` entries are auto-migrated with
inferred types.
### Adult content (`PornCollectionService`, `AdultView`)
The `porn-rotation.py` collection logic ported native. Doubly gated: the
`ENABLE_ADULT` compile flag and the runtime `pornFeature` setting (off by
default — the app ships with adult content concealed; a discreet sidebar toggle
reveals the Adult tab). Which library types count as adult is data, not
hardcoded: `LibraryConfig` types carry an `adult` flag (the default `porn` type
has it).
### VPN subsystem (`VPN/`)
OVPN profile parsing + store (`OVPNProfile`, `VPNConfigStore`), credentials in
the Keychain (`VPNCredentialStore`, via Security.framework), and a
`VPNController` with a settings UI — so a roaming device can reach the overlay
without hand-managed tunnels.
### Settings (`SettingsStore`)
Small tolerant-decode `settings.json`: adult gating (`pornFeature`,
`surfaceAdultOnHome`, `switchToAdultOnlyHome`), hover previews, media-key
forwarding (`NowPlayingController`), and offline-cache sizing
(`offlineEpisodes`/`offlineShows`).
### Persistence locations
See [operations.md](./operations.md#config--state-locations) for the full table.
## 2b. iOS companion app (`Sources/TVAnarchyiOS`)
A separate iOS target (with `Tests/TVAnarchyiOSUITests`): VLCKit-based player
(`VLCPlayerModel`, `PlayerScreen`), library browsing, on-device downloads
(`DownloadManager`/`DownloadsView`), and a remote-control surface
(`RemoteView`). It talks HTTP to a bridge (`BridgeClient`/`BridgeSettings`,
default port `8787`, optional token) rather than SSH'ing anywhere itself. The
bridge **server** is not part of this repo's `mcp/` tree — it lives with
`plum-control-mcp`'s deployment on plum.
## 3. Helper subprocesses
- **`governor/` (`portable-net-tv`, TS/Bun).** A standalone launchd daemon on

View file

@ -6,7 +6,7 @@ are detailed in [data-model.md](./data-model.md).
## The app
Distribution is two-tier: **dev builds are local** (built on the build box,
installed to `~/Applications`, never published); **releases are durable** and
installed locally, never published); **releases are durable** and
published to forge.black (Forgejo), where any node installs/updates from them
without a toolchain or source.
@ -17,7 +17,12 @@ without a toolchain or source.
```
Stamps build identity (git SHA / time → `BuildStamp.swift`), runs `xcodegen`,
builds Release into `build/dd`, and copies `TVAnarchy.app` to `~/Applications`.
builds Release into `build/dd`, and installs `TVAnarchy.app` to the
OS-appropriate location: `/Applications` when writable (the standard macOS
location — admin users, no sudo), else `~/Applications` (Apple's per-user
fallback for non-admin accounts). `TVANARCHY_DEST` overrides; non-macOS fails
loud. A stale copy at the other candidate location is removed on install so
two builds can't drift.
The one irreducible manual step is **quit + relaunch** — a running native app
can't be hot-swapped. The sidebar build stamp (`v<ver> · <sha> · <time>`) makes
a stale copy obvious. (Set `TVANARCHY_DD=path` to relocate the derived data;
@ -44,7 +49,8 @@ tools/update.sh [--force] # first run installs; later runs no-op if cur
Pulls the latest release zip from forge.black (`FORGEJO_API` defaults to the
mesh-stable overlay `http://10.9.0.4:3000`), compares the tag to the installed
`CFBundleShortVersionString`, and if newer swaps it into `~/Applications` and
`CFBundleShortVersionString`, and if newer swaps it into the same resolved
Applications location (migrating away any copy at the old location) and
strips the Gatekeeper quarantine xattr (the build is unsigned). A read token is
needed only if the repo is private. Then quit + relaunch.

View file

@ -12,7 +12,13 @@ Where the project is and the de-risked path to the full vision. Architecture is
| App — library browser | ✅ Shipped | black index fast-path + local `MEDIA_ROOTS` walk + registry fallback (no NFS) |
| App — downloads (search + transmission) | ✅ Shipped | via `mcp` CLI; search needs FlareSolverr |
| App — metadata enrichment + artwork | ✅ Shipped | regex parse + TMDB/IMDb/keyless + ffmpeg frame-grab |
| App — all UI (Home/Player/Library/Search/Downloads/Metadata/Hosts/Logs) | ✅ Shipped | wired, no TODO/FIXME/`fatalError` debt |
| App — all UI (Home/Player/Library/Search/Downloads/Metadata/Adult/Devices/Logs/Settings) | ✅ Shipped | wired, no TODO/FIXME/`fatalError` debt |
| App — device registry (Devices tab) | ✅ Shipped (registry only) | `DeviceConfig`: type→services presets mapping to fleet classes, per-device load badge; duty *engine* still unbuilt (see fleet below) |
| App — adult content tab | ✅ Shipped | `porn-rotation.py` ported to `PornCollectionService`; `ENABLE_ADULT` compile flag + runtime `pornFeature` setting, concealed by default |
| App — VPN subsystem | ✅ Shipped | OVPN profile/credential stores (Keychain), controller, settings UI |
| App — offline cache + Now Playing/media keys | ✅ Shipped | `OfflineCacheController`, `NowPlayingController`, bandwidth policy |
| iOS app (`TVAnarchyiOS`) | ✅ Shipped (companion) | VLCKit player, library, downloads, remote control via HTTP bridge (default `:8787`); the bridge *server* is not in this repo's `mcp/` tree |
| Distribution (release/update) | ✅ Shipped | `tools/release.sh` → Forgejo on forge.black; `tools/update.sh` installs/updates any node without a toolchain |
| `governor` (`portable-net-tv`) | ✅ Shipped (single-host) | watch tracking + prefetch buffer |
| `mcp` (`plum-control-mcp`) | ✅ Shipped | VLC / black-tv / transmission / display tools |
| `recommender` | ✅ Shipped | enrichment + local recs |
@ -28,10 +34,10 @@ is fully specified and entirely unbuilt.
### Repo-state note
The main checkout is mid-reorg: the committed `main` tree still tracks the old
`PlumTV`/`PlumTVCore` sources, while the working tree has the renamed `TVAnarchy`
sources plus the `governor/`, `mcp/`, `recommender/`, `fleet/`, and `tools/`
trees **untracked**. Committing this reorg is the prerequisite to everything below.
The `PlumTV → TVAnarchy` rename and the helper subsystems (`governor/`, `mcp/`,
`recommender/`, `search/`, `fleet/`, `tools/`) were committed to `main` on
2026-06-09 as a series of atomic commits (`41afc1c``b44b5a2`). Stage 0
(hygiene) below is **done**.
## Remaining work — detail
@ -64,10 +70,15 @@ peer-source policy, `peers_for`/`custodians_of` — is design-only.
From the design spec. Each stage ships on its own; later stages depend on earlier.
0. **Hygiene (prerequisite)** — commit the `PlumTV → TVAnarchy` rename and the
untracked helper subsystems so they are under version control.
0. ~~**Hygiene (prerequisite)**~~ — ✅ done 2026-06-09: the `PlumTV → TVAnarchy`
rename and the helper subsystems are committed on `main`.
1. **Host registry + duty assignment, single fleet** (black + apricot + plum +
phone). No mesh, no Discord. Unifies what's run by hand today.
*Partially started on the app side:* the Devices tab + `DeviceConfig`
(`devices.json`) already model device **types** (cellphone/laptop/storage/
seedbox/broadcast → fleet classes consumer/roamer/server/seedbox/broadcast)
with overridable per-device **services** presets. What remains is the
governor-side duty *assignment engine* over that registry.
*(Run the governor-generalization track alongside stages 13.)*
2. **Seedbox source + `public_swarm_face` duty.** Adds an always-on custodian;
proves the source model on the zero-risk source first. ("Add a seedbox" is the

View file

@ -2,7 +2,7 @@
# Cut a RELEASE of TVAnarchy and publish it to forge.black (Forgejo).
#
# Two-tier distribution: day-to-day `./build-install.sh` builds are ephemeral
# (tmp, install-to-~/Applications, gone). A release is the durable, shareable
# (tmp, install-to-/Applications, gone). A release is the durable, shareable
# artifact — built once here, tagged, and uploaded to the Forgejo repo so any
# node on the mesh can install/update from it WITHOUT the Xcode toolchain or
# source. Nodes pull with `tools/update.sh` (or the documented curl one-liner).
@ -37,7 +37,12 @@ trap 'rm -rf "$DD"' EXIT
echo "→ building release into $DD"
TVANARCHY_DD="$DD" ./build-install.sh
APP="$HOME/Applications/TVAnarchy.app"
# Same destination resolution as build-install.sh (which we just ran), so we zip
# the app it actually installed: TVANARCHY_DEST override → /Applications when
# writable → ~/Applications fallback.
if [ -n "${TVANARCHY_DEST:-}" ]; then APP="$TVANARCHY_DEST"
elif [ -w /Applications ]; then APP="/Applications/TVAnarchy.app"
else APP="$HOME/Applications/TVAnarchy.app"; fi
[ -d "$APP" ] || { echo "✗ build-install did not produce $APP" >&2; exit 1; }
VER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")"
BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist" 2>/dev/null || echo 0)"

View file

@ -13,9 +13,26 @@ set -euo pipefail
API="${FORGEJO_API:-http://10.9.0.4:3000}"
OWNER="${FORGEJO_OWNER:-lilith}"
REPO="${FORGEJO_REPO:-tv-anarchy}"
DEST="$HOME/Applications/TVAnarchy.app"
FORCE=""; [ "${1:-}" = "--force" ] && FORCE=1
# Where TVAnarchy.app installs. Duplicated from build-install.sh (kept in sync)
# so this script stays curl-able standalone:
# TVANARCHY_DEST env → explicit override, used verbatim
# macOS, /Applications writable → /Applications (the standard location —
# Finder's "Applications", admin group, no sudo)
# macOS, not writable (non-admin)→ ~/Applications (Apple's per-user location)
# anything else → fail loud; the .app bundle is macOS-only
resolve_dest() {
if [ -n "${TVANARCHY_DEST:-}" ]; then printf '%s\n' "$TVANARCHY_DEST"; return; fi
[ "$(uname -s)" = "Darwin" ] || { echo "✗ TVAnarchy.app is macOS-only (this is $(uname -s))." >&2; return 1; }
if [ -w /Applications ]; then
printf '/Applications/TVAnarchy.app\n'
else
printf '%s/Applications/TVAnarchy.app\n' "$HOME"
fi
}
DEST="$(resolve_dest)"
TOKEN="${FORGEJO_TOKEN:-}"
TOKEN_FILE="$HOME/.config/tv-anarchy/forgejo-token"
[ -z "$TOKEN" ] && [ -f "$TOKEN_FILE" ] && TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
@ -33,8 +50,21 @@ print(r["tag_name"], asset["browser_download_url"] if asset else "")
[ -n "$URL" ] || { echo "✗ release $TAG has no .zip asset." >&2; exit 1; }
# --- compare to the installed copy (releases are tagged v<marketing version>)
installed="none"
[ -d "$DEST" ] && installed="v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$DEST/Contents/Info.plist" 2>/dev/null || echo '?')"
installed="none"; stale=""
if [ -d "$DEST" ]; then
installed="v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$DEST/Contents/Info.plist" 2>/dev/null || echo '?')"
elif [ -z "${TVANARCHY_DEST:-}" ]; then
# No copy at the resolved location — an older install may sit at the other
# auto candidate (the layout moved from ~/Applications to /Applications).
# Count it for the version line and migrate it away after the install below.
# Skipped under an explicit TVANARCHY_DEST (a test install must not touch it).
for other in "/Applications/TVAnarchy.app" "$HOME/Applications/TVAnarchy.app"; do
if [ "$other" != "$DEST" ] && [ -d "$other" ]; then
stale="$other"
installed="v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$other/Contents/Info.plist" 2>/dev/null || echo '?') (at $other)"
fi
done
fi
if [ "$installed" = "$TAG" ] && [ -z "$FORCE" ]; then
echo "✓ already on $TAG — up to date (use --force to reinstall)."; exit 0
fi
@ -49,11 +79,16 @@ ditto -x -k "$TMP/app.zip" "$TMP/unpacked"
src="$(/usr/bin/find "$TMP/unpacked" -maxdepth 2 -name 'TVAnarchy.app' -print -quit)"
[ -n "$src" ] || { echo "✗ no TVAnarchy.app inside the release zip." >&2; exit 1; }
mkdir -p "$HOME/Applications"
mkdir -p "$(dirname "$DEST")"
rm -rf "$DEST"
ditto "$src" "$DEST"
# Unsigned build copied across machines → clear Gatekeeper quarantine so it opens.
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
# One install location only: drop the old copy at the other candidate (if any)
# so the launched app can never silently be a stale build.
if [ -n "$stale" ]; then
rm -rf "$stale" && echo " removed stale copy at $stale"
fi
echo "✓ installed $TAG$DEST"
echo " quit any running TVAnarchy and relaunch to pick this up."