diff --git a/docs/operations.md b/docs/operations.md index c519b4b..aecc1b3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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-.zip` | `/Applications` (admin) else `~/Applications`; Gatekeeper quarantine stripped; old-location copy migrated away | +| Ubuntu (classic Linux) | `TVAnarchy--linux-.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--windows-.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 diff --git a/governor/src/fleet/custody.ts b/governor/src/fleet/custody.ts new file mode 100644 index 0000000..3828123 --- /dev/null +++ b/governor/src/fleet/custody.ts @@ -0,0 +1,132 @@ +// Custody floor — every wanted title keeps ≥N complete copies; dropping to +// N−1 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(); + 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); +} diff --git a/governor/src/fleet/duties.ts b/governor/src/fleet/duties.ts new file mode 100644 index 0000000..b3f8d35 --- /dev/null +++ b/governor/src/fleet/duties.ts @@ -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(); + 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, next: Map): 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; +} diff --git a/governor/src/fleet/registry.ts b/governor/src/fleet/registry.ts new file mode 100644 index 0000000..a3ac839 --- /dev/null +++ b/governor/src/fleet/registry.ts @@ -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; + sources?: Array & { 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; +} + +export interface FleetRegistry { + hosts: FleetHost[]; + sources: Source[]; + floorCopies: number; + staticHoldings: Record; + warnings: string[]; +} + +// --- derivation ------------------------------------------------------------- + +/** DeviceType → fleet class, mirroring DeviceType.fleetClass in DeviceConfig.swift. */ +const TYPE_TO_CLASS: Record = { + 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(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(DEVICES_PATH), readJson(FLEET_PATH)); +} diff --git a/governor/src/fleet/types.ts b/governor/src/fleet/types.ts new file mode 100644 index 0000000..edfa06e --- /dev/null +++ b/governor/src/fleet/types.ts @@ -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; + /** 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; +} diff --git a/tools/release.sh b/tools/release.sh index 2d70d38..35932e9 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -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) diff --git a/tools/update.sh b/tools/update.sh index ec0c60b..34d551d 100755 --- a/tools/update.sh +++ b/tools/update.sh @@ -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-.zip → /Applications (admin) or ~/Applications +# Linux → TVAnarchy--linux-.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--windows-.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= 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) -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"