diff --git a/Sources/TVAnarchy/DevicesView.swift b/Sources/TVAnarchy/DevicesView.swift index 0d2b673..3cceff8 100644 --- a/Sources/TVAnarchy/DevicesView.swift +++ b/Sources/TVAnarchy/DevicesView.swift @@ -55,6 +55,9 @@ struct DevicesView: View { .help("Cache the next episodes of your recent shows to this device") } if let s = controller.hostStatsByID[d.id] { loadPill(s) } + if let issues = controller.hostStatsByID[d.id]?.deps?.issues, !issues.isEmpty { + depsPill(issues) + } if case .outdated(let deployed)? = controller.helperFreshness(d.id) { outdatedPill(deployed: deployed) } @@ -171,6 +174,11 @@ struct DevicesView: View { s.load1, s.load5, s.load15, s.cores) + (s.mpv_cpu.map { String(format: " · mpv %.0f%% CPU", $0) } ?? "")) } + if let facts = controller.hostStatsByID[d.id]?.deps?.facts, !facts.isEmpty { + summaryRow("Dependencies", facts.map { + ($0.severity == .ok ? "" : "⚠ ") + $0.text + }.joined(separator: " · ")) + } if let line = deploymentLine(d) { summaryRow("Service", line) } } if controller.canRestartService(d.id) || controller.canUpdateService(d.id) { @@ -249,6 +257,21 @@ struct DevicesView: View { } } + /// Dependent-service warnings — shown only when something is actually wrong + /// (healthy dependencies stay silent; the full report lives in the summary). + /// One issue shows its message outright; several collapse to a count. + private func depsPill(_ issues: [DepFact]) -> some View { + let isError = issues.contains { $0.severity == .error } + let c: Color = isError ? .red : .orange + return Label(issues.count == 1 ? issues[0].text : "\(issues.count) issues", + systemImage: isError ? "exclamationmark.octagon" : "exclamationmark.triangle") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(c.opacity(0.2), in: Capsule()) + .foregroundStyle(c) + .help(issues.map(\.text).joined(separator: " · ")) + } + /// Stale-deploy badge: the helper script running on the device is not the /// one vendored in this repo (or predates self-reporting entirely), so the /// app may be speaking verbs the device doesn't know yet. Redeploy per diff --git a/Sources/TVAnarchyCore/HostStats.swift b/Sources/TVAnarchyCore/HostStats.swift index 96474fc..dfd7695 100644 --- a/Sources/TVAnarchyCore/HostStats.swift +++ b/Sources/TVAnarchyCore/HostStats.swift @@ -12,6 +12,75 @@ public struct HostStats: Decodable, Sendable, Equatable { /// compares it against the repo's vendored copy to flag stale deploys. /// Absent from helpers that predate self-reporting (itself a stale sign). public let helper_sha: String? + /// Dependent-service facts (absent from pre-deps helpers). + public let deps: HostDeps? +} + +/// Facts about the services a device's duties depend on, as the helper reports +/// them — raw states only. What's *interesting* (worth surfacing in the UI) is +/// judged app-side by `issues`, so the thresholds live in one testable place. +public struct HostDeps: Decodable, Sendable, Equatable { + public let transmission: String? // systemd state: active/inactive/failed/unknown + public let mpv_unit: String? // the player unit; inactive just means idle + public let mpv_socket: Bool? + public let media_root: Bool? // does the media root directory exist + public let display: String? // DRM connector: connected/disconnected/unknown + public let disk_free_gb: Double? // free space under the media root + public let disk_used_pct: Int? + + public init(transmission: String? = nil, mpv_unit: String? = nil, + mpv_socket: Bool? = nil, media_root: Bool? = nil, display: String? = nil, + disk_free_gb: Double? = nil, disk_used_pct: Int? = nil) { + self.transmission = transmission; self.mpv_unit = mpv_unit + self.mpv_socket = mpv_socket; self.media_root = media_root; self.display = display + self.disk_free_gb = disk_free_gb; self.disk_used_pct = disk_used_pct + } +} + +/// One dependent-service fact, rendered and judged. `error` means a duty is +/// broken right now (transmission down, media root gone, disk effectively +/// full); `warning` means degraded / heading for trouble (disk filling, TV +/// unplugged, stale socket); `ok` is background detail for the summary. +public struct DepFact: Equatable, Sendable { + public enum Severity: Equatable, Sendable, Comparable { case ok, warning, error } + public let text: String + public let severity: Severity + public init(_ text: String, _ severity: Severity = .ok) { + self.text = text; self.severity = severity + } +} + +public extension HostDeps { + /// Every reported fact, judged — the single place the thresholds live. The + /// summary shows them all; the row surfaces only the non-ok subset. + var facts: [DepFact] { + var out: [DepFact] = [] + if let t = transmission { + out.append(DepFact("transmission \(t)", t == "active" ? .ok : .error)) + } + if media_root == false { + out.append(DepFact("media root missing", .error)) + } + if let pct = disk_used_pct { + let free = disk_free_gb.map { + $0 >= 1024 ? String(format: ", %.1f TB free", $0 / 1024) + : String(format: ", %.0f GB free", $0) + } ?? "" + out.append(DepFact("disk \(pct)% full\(free)", + pct >= 97 ? .error : pct >= 90 ? .warning : .ok)) + } + if let d = display { + out.append(DepFact("TV \(d)", d == "connected" ? .ok : .warning)) + } + if mpv_socket == true, let u = mpv_unit, u != "active" { + out.append(DepFact("stale mpv socket (unit \(u))", .warning)) + } + return out + } + + /// The "interesting" subset — empty when everything is healthy, which is + /// exactly when the row should stay quiet. + var issues: [DepFact] { facts.filter { $0.severity != .ok } } } /// A target that can report its host's load (only black — it's a real machine diff --git a/Tests/TVAnarchyCoreTests/HostDepsTests.swift b/Tests/TVAnarchyCoreTests/HostDepsTests.swift new file mode 100644 index 0000000..49a69bf --- /dev/null +++ b/Tests/TVAnarchyCoreTests/HostDepsTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import TVAnarchyCore + +/// Dependent-service reporting: the helper sends raw facts; `HostDeps.facts` +/// is the one place that judges what's interesting. Healthy → all-ok facts and +/// an empty `issues` (the row pill stays off); broken duties → errors. +final class HostDepsTests: XCTestCase { + func testStatsDecodeWithAndWithoutDeps() throws { + let new = #""" + {"load1":0.5,"load5":0.4,"load15":0.3,"cores":24,"mpv_cpu":null, + "helper_sha":"abc", + "deps":{"transmission":"active","mpv_unit":"inactive","mpv_socket":false, + "media_root":true,"display":"connected", + "disk_free_gb":40982,"disk_used_pct":38}} + """# + let old = #"{"load1":0.5,"load5":0.4,"load15":0.3,"cores":8,"mpv_cpu":null}"# + let n = try JSONDecoder().decode(HostStats.self, from: Data(new.utf8)) + let o = try JSONDecoder().decode(HostStats.self, from: Data(old.utf8)) + XCTAssertEqual(n.deps?.transmission, "active") + XCTAssertEqual(n.deps?.disk_used_pct, 38) + XCTAssertNil(o.deps) // pre-deps helper still decodes + } + + func testHealthyDepsRaiseNoIssues() { + let deps = HostDeps(transmission: "active", mpv_unit: "active", mpv_socket: true, + media_root: true, display: "connected", + disk_free_gb: 40982, disk_used_pct: 38) + XCTAssertTrue(deps.issues.isEmpty) + XCTAssertTrue(deps.facts.allSatisfy { $0.severity == .ok }) + // An idle player (unit inactive, no socket) is normal, not an issue. + XCTAssertTrue(HostDeps(mpv_unit: "inactive", mpv_socket: false).issues.isEmpty) + } + + func testBrokenDutiesAreErrors() { + XCTAssertEqual(HostDeps(transmission: "failed").issues, + [DepFact("transmission failed", .error)]) + XCTAssertEqual(HostDeps(media_root: false).issues, + [DepFact("media root missing", .error)]) + } + + func testDiskThresholds() { + XCTAssertEqual(HostDeps(disk_free_gb: 500, disk_used_pct: 38).issues, []) + XCTAssertEqual(HostDeps(disk_free_gb: 500, disk_used_pct: 92).issues, + [DepFact("disk 92% full, 500 GB free", .warning)]) + XCTAssertEqual(HostDeps(disk_free_gb: 90, disk_used_pct: 98).issues, + [DepFact("disk 98% full, 90 GB free", .error)]) + // ≥ 1 TB free renders in TB in the ok fact. + XCTAssertEqual(HostDeps(disk_free_gb: 40982, disk_used_pct: 38).facts, + [DepFact("disk 38% full, 40.0 TB free", .ok)]) + } + + func testDegradedStatesAreWarnings() { + // TV unplugged/off: mpv launches will fail, but the node is otherwise fine. + XCTAssertEqual(HostDeps(display: "disconnected").issues, + [DepFact("TV disconnected", .warning)]) + // The stale-socket state we hit live: socket present, unit dead. + XCTAssertEqual(HostDeps(mpv_unit: "inactive", mpv_socket: true).issues, + [DepFact("stale mpv socket (unit inactive)", .warning)]) + } +} diff --git a/docs/operations.md b/docs/operations.md index 839432c..e912382 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -141,10 +141,21 @@ manual `mcp/README.md` deploy step remains for hosts the app can't reach. Each row expands (chevron) into a **summary section**: backend + endpoints, role/fleet class + services, live connection/playback line, host load -(1/5/15-min + mpv decode CPU), and the helper-deployment line (deployed vs +(1/5/15-min + mpv decode CPU), the dependent-services line, and the +helper-deployment line (deployed vs repo hash, judged) — with the Restart / Update & restart buttons inline, so diagnosing and fixing a wedged device happens in one place. +**Dependent services**: `black-tv stats` also reports a `deps` object — +transmission unit state, mpv unit + socket, media-root presence, DRM display +state, disk used%/free under the media root. The helper sends raw facts; +`HostDeps.facts` (app-side, one testable place) judges severity: transmission +not active / media root missing / disk ≥97% = error, disk ≥90% / TV not +connected / stale socket = warning. The row shows a red/orange pill **only when +something is wrong** (a single issue shows its message, several collapse to a +count); the expanded summary always shows the full facts line with ⚠ on the +interesting ones. + ## governor (`portable-net-tv`) TypeScript/Bun standalone daemon on plum. diff --git a/docs/roadmap.md b/docs/roadmap.md index b4a698d..75bc395 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,7 +18,8 @@ Where the project is and the de-risked path to the full vision. Architecture is | 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 | +| Distribution (release/update) | ✅ Shipped (macOS) / 🟡 scaffolded (rest) | `tools/release.sh` → Forgejo; `tools/update.sh` resolves per-OS asset+dest (mac/linux/windows/android/iOS) via the single-source `tools/platform.sh`. Only the macOS asset can exist until the client is cross-platform (see north star) | +| Universal client (one app, all devices) | ❌ Designed | the spine: `blackd` → thin web client → wrap (Tauri/Capacitor) → retire Swift UIs; adult becomes a backend-entitled module. See "North star" | | `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 | @@ -38,6 +39,66 @@ reaper → `peers_for`) implemented and tested in the governor; what remains is actuation (cross-host copies), the WG fabric, and every stage that requires infrastructure outside this repo (seedbox, friends, Discord). +## North star — one client, every device + +The goal: **one app we install on every device** — iOS, Android, macOS, Ubuntu, +Bluefin, Windows — controlling the display endpoints (Roku, smart TVs) it can't +run on, **with the adult feature as an opt-in package on entitled devices only**. + +**Why today's app can't be that.** It is Swift + SwiftUI/AppKit/UIKit + VLCKit — +structurally Apple-only (29 UI files; the core leans on Observation/AppKit/ +MediaPlayer). It reaches only 2 of the 6 install targets. The per-platform +branches in `tools/update.sh` (linux/windows/android) are honest scaffolding for +release assets a Swift+SwiftUI build **cannot produce**. No amount of packaging +fixes that; the *client technology* is the constraint. + +**The unlock is already on the roadmap.** ~20% of `TVAnarchyCore` exists only to +reach black over ssh/Process, and most of the rest *fronts* state that truly +lives on black (the index, the watchlog, transmission, porn-rotation). Once +`blackd` (below) exposes those as HTTP, the client keeps almost no logic — it +becomes **browse + control + a `