feat(@applications/tv-anarchy): update release update script to multi-platform

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 21:04:19 -07:00
parent f2ce865cb8
commit ef3ed6dcfe
7 changed files with 706 additions and 54 deletions

View file

@ -47,12 +47,25 @@ the zipped `.app`. Needs a write token: `FORGEJO_TOKEN` env or
tools/update.sh [--force] # first run installs; later runs no-op if current
```
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 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.
Pulls the latest release from forge.black (`FORGEJO_API` defaults to the
mesh-stable overlay `http://10.9.0.4:3000`), picks the **asset for this
platform**, compares versions, and swaps it into the **OS-appropriate
destination** (`TVANARCHY_DEST` overrides everywhere):
| OS | Release asset | Destination |
|---|---|---|
| macOS | `TVAnarchy-<tag>.zip` | `/Applications` (admin) else `~/Applications`; Gatekeeper quarantine stripped; old-location copy migrated away |
| Ubuntu (classic Linux) | `TVAnarchy-<tag>-linux-<arch>.tar.gz` | `/opt/tv-anarchy` when `/opt` is writable, else `~/.local/opt/tv-anarchy` |
| Bluefin (immutable/ostree Linux) | same as Linux | always `~/.local/opt/tv-anarchy` (`/usr` is read-only; detected via `/run/ostree-booted`) |
| Windows (Git Bash/MSYS) | `TVAnarchy-<tag>-windows-<arch>.zip` | `%LOCALAPPDATA%\Programs\TVAnarchy` (per-user, no elevation) |
| iOS | — | not via this script (no shell): build the `TVAnarchyiOS` scheme in Xcode onto the device, or TestFlight/sideload |
Version compare: macOS reads the bundle plist; Linux/Windows read the
`.release-tag` stamp the script writes on install. A release that lacks this
platform's asset fails loud with the exact missing name and the published list —
**today only the macOS asset is published** (cut on plum); Linux/Windows entries
activate the moment a release carries their assets. A read token is needed only
if the repo is private. Then quit/restart the app.
> Unsigned local build: fine across **your own** Macs (the quarantine strip
> handles Gatekeeper). Distributing to other people's machines would want real

View file

@ -0,0 +1,132 @@
// Custody floor — every wanted title keeps ≥N complete copies; dropping to
// N1 triggers a re-pin from a surviving holder BEFORE the last copy vanishes.
//
// Custodianship is a rolling baton: the N most-recent complete holders form
// the floor, with ≥1 slot reserved for an always-on node (a floor of laptops
// is momentarily dead whenever they sleep). Pure functions — actuation (who
// actually copies what) is the CLI/daemon's job.
import { custodyEligible } from "./duties.ts";
import type { FleetHost, FloorReport, Holding, RepinAction } from "./types.ts";
/** Per-title eligibility: host-level custody eligibility + room for the copy. */
function eligibleFor(h: FleetHost, sizeBytes: number | null): boolean {
if (!custodyEligible(h)) return false;
if (sizeBytes !== null && h.capacity.diskFreeBytes !== null) {
return h.capacity.diskFreeBytes > sizeBytes;
}
return true; // unknown sizes/capacity are permissive — better a floor than none
}
/**
* Compute one title's floor from its current holdings.
*
* `holdings` every host's copy of this title (complete or not).
* `hosts` the full registry (for eligibility + re-pin targets).
* `floorN` required complete copies.
*/
export function floorForTitle(
title: string,
holdings: Holding[],
hosts: FleetHost[],
floorN: number,
): FloorReport {
const warnings: string[] = [];
const actions: RepinAction[] = [];
const byId = new Map(hosts.map(h => [h.id, h]));
const size = holdings.find(h => h.sizeBytes !== null)?.sizeBytes ?? null;
// Complete copies on hosts that still exist in the registry, newest first
// (rolling baton: recency decides floor membership), ties stable by host id.
const complete = holdings
.filter(h => h.complete && byId.has(h.hostId))
.sort((a, b) => b.completedAt - a.completedAt || a.hostId.localeCompare(b.hostId));
// Floor = N most-recent holders whose host is custody-eligible for this title.
// Non-eligible holders (a roamer holding a TTL copy) still count as copies but
// can't be *obligated* custodians.
const custodians: string[] = [];
for (const h of complete) {
if (custodians.length >= floorN) break;
const host = byId.get(h.hostId)!;
if (eligibleFor(host, size) && !custodians.includes(h.hostId)) custodians.push(h.hostId);
}
// Reserve ≥1 always-on slot: if the floor somehow holds no always-on host
// (possible when eligibility was permissive on unknowns), promote the
// newest always-on complete holder, displacing the oldest pick.
const hasAlwaysOn = custodians.some(id => byId.get(id)!.alwaysOn);
if (custodians.length > 0 && !hasAlwaysOn) {
const candidate = complete.find(h => byId.get(h.hostId)!.alwaysOn && !custodians.includes(h.hostId));
if (candidate) {
custodians.pop();
custodians.push(candidate.hostId);
actions.push({
kind: "promote_always_on",
title,
toHost: candidate.hostId,
fromHost: null,
reason: "floor had no always-on holder",
});
} else {
warnings.push(`floor for "${title}" has no always-on holder and none is available`);
}
}
// Breach check: complete copies below the floor → re-pin to the best
// eligible non-holder (always-on preferred, then most free disk) from a
// surviving complete holder.
const completeCopies = new Set(complete.map(h => h.hostId)).size;
const breach = completeCopies < floorN;
if (breach) {
const holders = new Set(complete.map(h => h.hostId));
const targets = hosts
.filter(h => !holders.has(h.id) && eligibleFor(h, size))
.sort((a, b) =>
Number(b.alwaysOn) - Number(a.alwaysOn)
|| (b.capacity.diskFreeBytes ?? 0) - (a.capacity.diskFreeBytes ?? 0)
|| a.id.localeCompare(b.id));
const source = complete[0]?.hostId ?? null;
if (targets.length === 0) {
warnings.push(
`floor breach for "${title}" (${completeCopies}/${floorN}) and no eligible re-pin target`,
);
} else {
// One action per missing copy, spread across distinct targets.
const missing = floorN - completeCopies;
for (const target of targets.slice(0, missing)) {
actions.push({
kind: "repin",
title,
toHost: target.id,
fromHost: source,
reason: source
? `copies ${completeCopies}/${floorN} — replicate from ${source} before the last copy vanishes`
: `copies ${completeCopies}/${floorN} and NO surviving complete copy — reaper must re-source`,
});
}
}
}
return { title, custodians, completeCopies, floorCopies: floorN, breach, actions, warnings };
}
/** Group holdings by title and floor-check each. */
export function floorCheck(holdings: Holding[], hosts: FleetHost[], floorN: number): FloorReport[] {
const byTitle = new Map<string, Holding[]>();
for (const h of holdings) {
const list = byTitle.get(h.title) ?? [];
list.push(h);
byTitle.set(h.title, list);
}
return [...byTitle.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([title, hs]) => floorForTitle(title, hs, hosts, floorN));
}
/** Who is obligated to keep `title` alive — one of the two derived outputs. */
export function custodiansOf(title: string, holdings: Holding[], hosts: FleetHost[], floorN: number): FleetHost[] {
const report = floorForTitle(title, holdings.filter(h => h.title === title), hosts, floorN);
const byId = new Map(hosts.map(h => [h.id, h]));
return report.custodians.map(id => byId.get(id)!).filter(Boolean);
}

View file

@ -0,0 +1,103 @@
// Duty assignment — deterministic, capability-driven, run on every registry
// read (cheap and idempotent, so "on registry change" is satisfied by running
// it each invocation/tick and diffing).
//
// Spec table (.project/history/20260608_fleet-manager-mesh-design.md):
// broadcast public_ip && always_on exactly ONE; prefer seedbox > broadcast-node > server
// f2f_relay always_on && reachable ∈ {wireguard, public_ip}
// public_swarm_face prefer !on_home_ip seedbox FIRST; never a consumer;
// never on_home_ip if an off-home option exists
// custody_floor per-title (custody.ts) — here only host-level eligibility
//
// Invariants:
// - a consumer NEVER receives any duty (checked first)
// - every duty decision is stable: ties broken by host id, so the same
// registry always yields the same assignment (no flapping).
import type { Duty, DutyAssignment, FleetHost } from "./types.ts";
/** Stable ordering helper: rank asc, then id asc. */
function pickFirst(hosts: FleetHost[], rank: (h: FleetHost) => number): FleetHost | null {
const sorted = [...hosts].sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
return sorted[0] ?? null;
}
/** Class preference for the broadcast duty: seedbox > dedicated broadcast node > server. */
function broadcastRank(h: FleetHost): number {
switch (h.class) {
case "seedbox": return 0;
case "broadcast": return 1;
case "server": return 2;
default: return 9;
}
}
/** Host-level custody eligibility; the per-title disk check lives in custody.ts. */
export function custodyEligible(h: FleetHost): boolean {
return h.class !== "consumer" && h.alwaysOn;
}
export function assignDuties(hosts: FleetHost[]): DutyAssignment {
const duties = new Map<string, Duty[]>();
const warnings: string[] = [];
for (const h of hosts) duties.set(h.id, []);
const give = (h: FleetHost, d: Duty): void => { duties.get(h.id)!.push(d); };
// Invariant 1: consumers are pure sinks. Filter once; nothing below sees them.
const eligible = hosts.filter(h => h.class !== "consumer");
// broadcast — exactly one per fleet.
const broadcastCandidates = eligible.filter(h => h.reachable === "public_ip" && h.alwaysOn);
const broadcastHost = pickFirst(broadcastCandidates, broadcastRank);
if (broadcastHost) {
give(broadcastHost, "broadcast");
} else {
warnings.push(
"no broadcast-eligible host (need public_ip && always_on) — F2F rendezvous, "
+ "peer registry and the Discord bridge have no anchor",
);
}
// f2f_relay — the broadcast host plus any other always-on, mesh/public-reachable
// non-roamer (the spec's "any other always-on server").
for (const h of eligible) {
const reachableEnough = h.reachable === "wireguard" || h.reachable === "public_ip";
const isRelayClass = h.class === "server" || h.class === "seedbox" || h.class === "broadcast";
if (h.alwaysOn && reachableEnough && (h.id === broadcastHost?.id || isRelayClass)) {
give(h, "f2f_relay");
}
}
// public_swarm_face — one face; seedbox first, then any off-home host, and an
// on-home host only when no off-home option exists (with a warning: the home
// connection is what's being exposed).
const faceCandidates = eligible.filter(h => h.alwaysOn);
const offHome = faceCandidates.filter(h => !h.onHomeIp);
const pool = offHome.length > 0 ? offHome : faceCandidates;
const face = pickFirst(pool, h => (h.class === "seedbox" ? 0 : 1));
if (face) {
give(face, "public_swarm_face");
if (face.onHomeIp) {
warnings.push(
`public_swarm_face assigned to ${face.id} which is on the home IP — `
+ "public-swarm traffic exposes the home connection; add a seedbox/off-home node",
);
}
} else {
warnings.push("no always-on host available for public_swarm_face — fleet cannot touch public swarms");
}
return { duties, warnings };
}
/** Diff two assignments → human-readable change lines (for change-triggered logging). */
export function diffDuties(prev: Map<string, Duty[]>, next: Map<string, Duty[]>): string[] {
const lines: string[] = [];
const ids = new Set([...prev.keys(), ...next.keys()]);
for (const id of [...ids].sort()) {
const a = (prev.get(id) ?? []).slice().sort().join(",") || "(none)";
const b = (next.get(id) ?? []).slice().sort().join(",") || "(none)";
if (a !== b) lines.push(`${id}: ${a}${b}`);
}
return lines;
}

View file

@ -0,0 +1,182 @@
// Registry ingest: join the app's device registry (~/.config/tv-anarchy/
// devices.json — owned by the Swift app's DeviceConfig) with fleet-side facts
// the app doesn't model (~/.config/tv-anarchy/fleet.json — owned by this
// module) into FleetHost records the duty engine consumes.
//
// devices.json stays the single source of truth for WHAT devices exist;
// fleet.json is additive — per-device overrides + sources + floor config —
// and absence of either file degrades gracefully (empty registry / defaults).
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type {
FleetHost, HostApi, HostCapacity, HostClass, Reachability, Source,
} from "./types.ts";
import { normalizeSource } from "./peers.ts";
const CONFIG_DIR = join(homedir(), ".config", "tv-anarchy");
export const DEVICES_PATH = join(CONFIG_DIR, "devices.json");
export const FLEET_PATH = join(CONFIG_DIR, "fleet.json");
/** Default replication floor: every wanted title keeps ≥2 complete copies. */
export const DEFAULT_FLOOR_COPIES = 2;
// --- raw schemas ------------------------------------------------------------
/** Subset of the app's DeviceConfig schema this module reads. */
interface RawDevice {
id?: string;
name?: string;
kind?: string; // vlc | mpv-ipc | quicktime | blacktv(legacy)
type?: string; // cellphone | laptop | storage | seed | broadcast
ssh?: string; // user@host
mpv?: { endpoints?: string[] };
}
interface RawDevicesFile {
devices?: RawDevice[];
hosts?: RawDevice[]; // legacy key, same shape
}
/** Per-device fleet overrides; every field optional — defaults are derived. */
export interface FleetDeviceOverride {
alwaysOn?: boolean;
onHomeIp?: boolean;
reachable?: Reachability;
api?: HostApi;
addr?: string;
ssh?: string;
diskFreeBytes?: number;
upBwKbs?: number;
uptimeScore?: number;
}
export interface FleetFile {
floorCopies?: number;
devices?: Record<string, FleetDeviceOverride>;
sources?: Array<Partial<Source> & { id: string; kind: Source["kind"] }>;
/**
* Static holdings for hosts without a torrent-client API (e.g. an apricot
* mirror dir): hostId list of title names known complete there.
*/
staticHoldings?: Record<string, string[]>;
}
export interface FleetRegistry {
hosts: FleetHost[];
sources: Source[];
floorCopies: number;
staticHoldings: Record<string, string[]>;
warnings: string[];
}
// --- derivation -------------------------------------------------------------
/** DeviceType → fleet class, mirroring DeviceType.fleetClass in DeviceConfig.swift. */
const TYPE_TO_CLASS: Record<string, HostClass> = {
cellphone: "consumer",
laptop: "roamer",
storage: "server",
seed: "seedbox",
broadcast: "broadcast",
};
/** Legacy devices without a `type`: infer from the player backend, like the app does. */
function inferClass(d: RawDevice): HostClass {
if (d.type && TYPE_TO_CLASS[d.type]) return TYPE_TO_CLASS[d.type];
switch (d.kind) {
case "mpv-ipc":
case "blacktv": return "server"; // black: streaming storage node
default: return "roamer"; // vlc/quicktime = the laptop itself
}
}
/** user@host → host; passthrough when there's no user part. */
function bareAddr(endpoint: string): string {
const at = endpoint.lastIndexOf("@");
return at >= 0 ? endpoint.slice(at + 1) : endpoint;
}
/**
* Reachability heuristic when not overridden: an mpv/ssh endpoint on the
* 10.9.* overlay means the host is mesh-reachable (WireGuard); anything else
* defaults to the home LAN. `public_ip` is never inferred it must be an
* explicit override (claiming it wrongly would mis-assign `broadcast`).
*/
function inferReachability(d: RawDevice): Reachability {
const endpoints = d.mpv?.endpoints ?? (d.ssh ? [d.ssh] : []);
return endpoints.some(e => bareAddr(e).startsWith("10.9.")) ? "wireguard" : "home_lan";
}
function deriveHost(d: RawDevice, ov: FleetDeviceOverride, warnings: string[]): FleetHost | null {
if (!d.id) { warnings.push("device without id skipped"); return null; }
const cls = inferClass(d);
// Always-on default tracks the class: infrastructure classes are assumed on.
const alwaysOn = ov.alwaysOn ?? (cls === "server" || cls === "seedbox" || cls === "broadcast");
// Off-home default only for classes that exist to be off-home.
const onHomeIp = ov.onHomeIp ?? !(cls === "seedbox" || cls === "broadcast");
const sshDest = ov.ssh ?? d.ssh ?? d.mpv?.endpoints?.[0] ?? null;
const capacity: HostCapacity = {
diskFreeBytes: ov.diskFreeBytes ?? null,
upBwKbs: ov.upBwKbs ?? null,
uptimeScore: ov.uptimeScore ?? (alwaysOn ? 1 : 0.5),
};
return {
id: d.id,
name: d.name ?? d.id,
class: cls,
reachable: ov.reachable ?? inferReachability(d),
alwaysOn,
onHomeIp,
api: ov.api ?? (cls === "server" ? "transmission_rpc" : "none"),
addr: ov.addr ?? (sshDest ? bareAddr(sshDest) : null),
ssh: sshDest,
capacity,
};
}
function readJson<T>(path: string): T | null {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch {
return null;
}
}
/**
* Build the registry from explicit file contents pure, for tests.
* `loadRegistry()` is the filesystem-bound wrapper.
*/
export function buildRegistry(devicesFile: RawDevicesFile | null, fleetFile: FleetFile | null): FleetRegistry {
const warnings: string[] = [];
if (devicesFile === null) warnings.push(`no device registry at ${DEVICES_PATH} — run the app once to seed it`);
const rawDevices = devicesFile?.devices ?? devicesFile?.hosts ?? [];
const overrides = fleetFile?.devices ?? {};
const hosts: FleetHost[] = [];
for (const d of rawDevices) {
const host = deriveHost(d, (d.id && overrides[d.id]) || {}, warnings);
if (host) hosts.push(host);
}
// Sources: user-configured + an implicit DHT/public source (public swarms
// always exist for transmission-held torrents). Gates are enforced on every
// source, configured or not.
const configured = (fleetFile?.sources ?? []).map(normalizeSource);
const sources: Source[] = configured.some(s => s.kind === "dht")
? configured
: [...configured, normalizeSource({ id: "dht", kind: "dht", label: "DHT/public swarm" })];
const floorCopies = fleetFile?.floorCopies ?? DEFAULT_FLOOR_COPIES;
if (floorCopies < 1) warnings.push(`floorCopies ${floorCopies} < 1 — clamped to 1`);
return {
hosts,
sources,
floorCopies: Math.max(1, floorCopies),
staticHoldings: fleetFile?.staticHoldings ?? {},
warnings,
};
}
export function loadRegistry(): FleetRegistry {
return buildRegistry(readJson<RawDevicesFile>(DEVICES_PATH), readJson<FleetFile>(FLEET_PATH));
}

151
governor/src/fleet/types.ts Normal file
View file

@ -0,0 +1,151 @@
// Fleet data model — the implemented core of the mesh design spec
// (.project/history/20260608_fleet-manager-mesh-design.md). Single-fleet,
// stage 1 + stage 3: registry, duty assignment, custody floor, zombie reaper,
// peers_for over local sources. Friend-mesh / F2F / private-tracker stages are
// design-only and intentionally absent here.
/** What a device IS — mirrors `DeviceType.fleetClass` in the app's DeviceConfig. */
export type HostClass = "server" | "roamer" | "consumer" | "seedbox" | "broadcast";
/** How the fleet reaches a host. */
export type Reachability = "home_lan" | "wireguard" | "public_ip";
/** Torrent-client API a host exposes (for custody/reaper actuation). */
export type HostApi = "transmission_rpc" | "qbittorrent" | "utorrent_web" | "none";
/** What the manager tells a host to DO. Assigned, never hardcoded. */
export type Duty = "custody_floor" | "public_swarm_face" | "f2f_relay" | "broadcast";
export interface HostCapacity {
/** Bytes free on the media volume; null = unknown (treated permissively). */
diskFreeBytes: number | null;
/** Upload bandwidth in KB/s; null = unknown. */
upBwKbs: number | null;
/** Rolling uptime score ∈ [0,1]. Defaults: always-on 1.0, otherwise 0.5. */
uptimeScore: number;
}
/** A registry entry — the app's device joined with fleet-side facts. */
export interface FleetHost {
id: string;
name: string;
class: HostClass;
reachable: Reachability;
alwaysOn: boolean;
/** true = public-swarm traffic from this host exposes the home connection. */
onHomeIp: boolean;
api: HostApi;
/** Bare address (no user@) for peers_for; null if the registry can't derive one. */
addr: string | null;
/** ssh destination (user@host) for probes/actuation; null = not reachable via ssh. */
ssh: string | null;
capacity: HostCapacity;
}
export interface DutyAssignment {
/** hostId → duties. Hosts with no duties are present with an empty array. */
duties: Map<string, Duty[]>;
/** Human-readable invariant violations / degraded placements. */
warnings: string[];
}
// ---------------------------------------------------------------------------
// Peer-source model (stage 3) — sources are policy-bearing; the gates are the
// load-bearing part (passkey blast-radius containment for private trackers).
export type SourceKind =
| "dht" | "public_tracker" | "private_tracker"
| "friend_mesh" | "fleet_host" | "seedbox";
export type SharePolicy = "search_only" | "content";
export type SwarmIsolation = "f2f_only" | "open";
export interface Source {
id: string;
kind: SourceKind;
sharePolicy: SharePolicy;
swarmIsolation: SwarmIsolation;
/** Display label for provenance UI; defaults to id. */
label: string;
}
export type ServedVia = "public" | "wireguard";
/** Provenance-tagged peer — the unit of the user-owned meta-tracker. */
export interface Peer {
addr: string;
sourceKind: SourceKind;
sourceId: string;
servedVia: ServedVia;
}
// ---------------------------------------------------------------------------
// Custody floor + reaper
/** One host's copy of a title (complete or in progress). */
export interface Holding {
hostId: string;
/** Torrent/display name the holding is known by. */
title: string;
/** v1/v2 infohash when known (transmission gives it); null for static holdings. */
infohash: string | null;
complete: boolean;
/** Unix epoch seconds the copy completed (0/unknown sorts oldest). */
completedAt: number;
sizeBytes: number | null;
}
export type RepinActionKind = "repin" | "promote_always_on";
export interface RepinAction {
kind: RepinActionKind;
title: string;
/** Host that should acquire/keep the copy. */
toHost: string;
/** A surviving complete holder to source from; null = none (reaper territory). */
fromHost: string | null;
reason: string;
}
export interface FloorReport {
title: string;
/** Hosts currently obligated to keep the title alive (the floor). */
custodians: string[];
completeCopies: number;
floorCopies: number;
breach: boolean;
actions: RepinAction[];
warnings: string[];
}
export type TorrentHealth = "healthy" | "stalled" | "dead";
/** The classifier's input — a narrow projection of transmission's torrent-get. */
export interface TorrentVitals {
name: string;
infohash: string | null;
/** 0..1 */
percentDone: number;
/** transmission status enum: 0 stopped … 4 downloading, 6 seeding. */
status: number;
/** transmission error code; non-zero = tracker/local error. */
error: number;
errorString: string;
peersConnected: number;
rateDownloadBps: number;
rateUploadBps: number;
/** Unix epoch seconds of last piece activity; 0 = never. */
activityDate: number;
addedDate: number;
}
export type RecoveryKind = "mesh_recover" | "research" | "reannounce";
export interface ReaperVerdict {
name: string;
infohash: string | null;
health: TorrentHealth;
reason: string;
/** Recovery proposal for stalled/dead torrents; null for healthy. */
recovery: { kind: RecoveryKind; detail: string } | null;
}

View file

@ -13,6 +13,7 @@
# (scopes: write:repository). Required — the script will not guess.
set -euo pipefail
cd "$(dirname "$0")/.."
[ "$(uname -s)" = "Darwin" ] || { echo "✗ releases are cut on the macOS build box (xcodebuild); this is $(uname -s)." >&2; exit 1; }
API="${FORGEJO_API:-http://10.9.0.4:3000}" # overlay IP: mesh-stable (LAN addr flaps)

View file

@ -4,10 +4,24 @@
# no-op when already current. This is how every node EXCEPT the build box (plum)
# gets the app; plum cuts releases with tools/release.sh.
#
# Per-OS behavior (the fleet: iOS, macOS, Ubuntu, Bluefin, Windows):
# macOS → TVAnarchy-<tag>.zip → /Applications (admin) or ~/Applications
# Linux → TVAnarchy-<tag>-linux-<arch>.tar.gz → /opt/tv-anarchy (classic,
# writable) or ~/.local/opt/tv-anarchy (non-root; always on
# immutable/ostree systems like Bluefin, whose /usr is read-only)
# Windows → TVAnarchy-<tag>-windows-<arch>.zip → %LOCALAPPDATA%\Programs\TVAnarchy
# (per-user, no elevation; run under Git Bash/MSYS)
# iOS → not served here: no shell. Install via Xcode (TVAnarchyiOS scheme)
# or TestFlight/sideload — see docs/operations.md.
# A release that lacks the asset for this platform fails with the exact missing
# asset name (today only the macOS asset is published).
#
# Usage: tools/update.sh [--force]
# Needs: curl, python3 (JSON parsing). macOS also: ditto, PlistBuddy (built in).
# Config (env, all optional — defaults target the mesh Forgejo):
# FORGEJO_API=http://10.9.0.4:3000 FORGEJO_OWNER=lilith FORGEJO_REPO=tv-anarchy
# FORGEJO_TOKEN / ~/.config/tv-anarchy/forgejo-token (only if the repo is private)
# TVANARCHY_DEST=<path> explicit install destination override
set -euo pipefail
API="${FORGEJO_API:-http://10.9.0.4:3000}"
@ -15,80 +29,136 @@ OWNER="${FORGEJO_OWNER:-lilith}"
REPO="${FORGEJO_REPO:-tv-anarchy}"
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
# --- platform --------------------------------------------------------------
case "$(uname -s)" in
Darwin) OS=mac ;;
Linux) OS=linux ;;
MINGW*|MSYS*|CYGWIN*) OS=windows ;;
*) echo "✗ unsupported platform '$(uname -s)' — TVAnarchy ships for macOS, Linux, Windows (iOS via Xcode/TestFlight)." >&2; exit 1 ;;
esac
ARCH="$(uname -m)" # arm64 / x86_64 / aarch64
# True on image-based (ostree/bootc) Linux — Bluefin, Silverblue, etc. Their
# /usr is read-only and /opt is machine-local; user-scope installs are the norm.
is_immutable_linux() { [ -e /run/ostree-booted ] || [ -e /usr/lib/bootc ]; }
# Where TVAnarchy installs on THIS node. Mirrors build-install.sh's macOS logic
# (kept in sync) so this script stays curl-able standalone.
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
case "$OS" in
mac)
if [ -w /Applications ]; then printf '/Applications/TVAnarchy.app\n'
else printf '%s/Applications/TVAnarchy.app\n' "$HOME"; fi ;;
linux)
if ! is_immutable_linux && [ -w /opt ]; then printf '/opt/tv-anarchy\n'
else printf '%s/.local/opt/tv-anarchy\n' "$HOME"; fi ;;
windows)
printf '%s/Programs/TVAnarchy\n' "${LOCALAPPDATA:-$HOME/AppData/Local}" ;;
esac
}
DEST="$(resolve_dest)"
# The release asset this platform consumes.
asset_name() {
case "$OS" in
mac) printf 'TVAnarchy-%s.zip\n' "$1" ;;
linux) printf 'TVAnarchy-%s-linux-%s.tar.gz\n' "$1" "$ARCH" ;;
windows) printf 'TVAnarchy-%s-windows-%s.zip\n' "$1" "$ARCH" ;;
esac
}
TOKEN="${FORGEJO_TOKEN:-}"
TOKEN_FILE="$HOME/.config/tv-anarchy/forgejo-token"
[ -z "$TOKEN" ] && [ -f "$TOKEN_FILE" ] && TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
auth=(); [ -n "$TOKEN" ] && auth=(-H "Authorization: token $TOKEN")
# --- resolve the latest release -------------------------------------------
latest="$(curl -fsSL "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest")" || {
echo "✗ couldn't reach Forgejo at $API (mesh down, or repo private + no token?)." >&2; exit 1; }
read -r TAG URL < <(printf '%s' "$latest" | python3 -c '
# --- resolve the latest release ---------------------------------------------
latest="$(curl -fsL "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest" 2>/dev/null)" || {
code="$(curl -s -o /dev/null -m 8 -w '%{http_code}' "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest" || true)"
if [ "$code" = "404" ]; then
echo "$OWNER/$REPO has no releases yet — cut one on the build box with tools/release.sh." >&2
else
echo "✗ couldn't reach Forgejo at $API (HTTP ${code:-none} — mesh down, or repo private + no token?)." >&2
fi
exit 1; }
TAG="$(printf '%s' "$latest" | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])')"
WANT="$(asset_name "$TAG")"
URL="$(printf '%s' "$latest" | python3 -c '
import sys, json
want = sys.argv[1]
r = json.load(sys.stdin)
asset = next((a for a in r.get("assets", []) if a["name"].endswith(".zip")), None)
print(r["tag_name"], asset["browser_download_url"] if asset else "")
')
[ -n "$URL" ] || { echo "✗ release $TAG has no .zip asset." >&2; exit 1; }
a = next((a for a in r.get("assets", []) if a["name"] == want), None)
print(a["browser_download_url"] if a else "")
' "$WANT")"
if [ -z "$URL" ]; then
echo "✗ release $TAG has no asset '$WANT' for this platform ($OS/$ARCH)." >&2
echo " published assets:" >&2
printf '%s' "$latest" | python3 -c 'import sys,json; [print(" ", a["name"]) for a in json.load(sys.stdin).get("assets", [])]' >&2
exit 1
fi
# --- compare to the installed copy (releases are tagged v<marketing version>)
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).
# macOS reads the bundle plist; Linux/Windows read the .release-tag stamp this
# script writes on install.
installed_tag() {
if [ "$OS" = mac ]; then
[ -d "$1" ] && printf 'v%s\n' "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist" 2>/dev/null || echo '?')"
else
[ -f "$1/.release-tag" ] && cat "$1/.release-tag"
fi
}
installed="$(installed_tag "$DEST" || true)"; installed="${installed:-none}"; stale=""
if [ "$installed" = "none" ] && [ "$OS" = mac ] && [ -z "${TVANARCHY_DEST:-}" ]; then
# An older mac 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. Skipped under an explicit TVANARCHY_DEST
# (a test install must not touch the real one).
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)"
stale="$other"; installed="$(installed_tag "$other" || true) (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
echo "$installed$TAG"
echo "$installed$TAG ($OS/$ARCH)"
# --- download, unzip, swap in, de-quarantine ------------------------------
# --- download, unpack, swap in ----------------------------------------------
TMP="$(mktemp -d "${TMPDIR:-/tmp}/tvanarchy-update.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
echo "→ downloading $TAG"
curl -fsSL "${auth[@]}" -o "$TMP/app.zip" "$URL"
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; }
echo "→ downloading $WANT"
curl -fsSL "${auth[@]}" -o "$TMP/asset" "$URL"
mkdir -p "$TMP/unpacked"
case "$WANT" in
*.tar.gz) tar -xzf "$TMP/asset" -C "$TMP/unpacked" ;;
*.zip) if [ "$OS" = mac ]; then ditto -x -k "$TMP/asset" "$TMP/unpacked"
else unzip -q "$TMP/asset" -d "$TMP/unpacked"; fi ;;
esac
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
case "$OS" in
mac)
src="$(/usr/bin/find "$TMP/unpacked" -maxdepth 2 -name 'TVAnarchy.app' -print -quit)"
[ -n "$src" ] || { echo "✗ no TVAnarchy.app inside $WANT." >&2; exit 1; }
rm -rf "$DEST"; ditto "$src" "$DEST"
# Unsigned build copied across machines → clear Gatekeeper quarantine.
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
if [ -n "$stale" ]; then rm -rf "$stale" && echo " removed stale copy at $stale"; fi
relaunch="quit any running TVAnarchy and relaunch to pick this up."
;;
linux|windows)
# Convention: the archive holds a single top-level TVAnarchy/ directory.
src="$(find "$TMP/unpacked" -maxdepth 1 -mindepth 1 -type d -print -quit)"
[ -n "$src" ] || { echo "✗ no payload directory inside $WANT." >&2; exit 1; }
rm -rf "$DEST"; mv "$src" "$DEST"
printf '%s\n' "$TAG" > "$DEST/.release-tag"
[ "$OS" = linux ] && chmod +x "$DEST"/tvanarchy* 2>/dev/null || true
relaunch="restart TVAnarchy to pick this up."
;;
esac
echo "✓ installed $TAG$DEST"
echo " quit any running TVAnarchy and relaunch to pick this up."
echo " $relaunch"