From 47dbd7b514bcdc722c127bdf4aebd98dc9ccbe82 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 28 Aug 2026 21:54:15 +0200 Subject: [PATCH 1/3] The host learns to end its own day: sleep, restart and shutdown as gated actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/host-actions.md P0 (host core) + P1 (web console). A reusable action registry — GET /api/v1/actions (per-caller permission + honest availability) and an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power built-ins, so the Wake-on-LAN round trip finally closes from the couch. Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL, so old Full records neither render Custom nor silently lack Power); typed close RejectReason::HostPower (0x6B) so ended sessions say why. Host: power executors (logind via zbus behind the already-shipped polkit rule, deliberately without -ignore-inhibit; Windows SeShutdownPrivilege + InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's live session blocks a cert-lane invoke (409); action.invoked on the event bus; denials logged once per (fingerprint, action) per boot. The plugin token gets neither route. Console: Host-power card (password-confirmed, the update-apply recipe) + a BFF route that keeps the invoke off the ungated catch-all; Host power toggle in the Access sheet. Mirrors: web/Kotlin/Swift consts + legacy rule, pf-client-core preset_label now masks (an old client no longer renders a new host's Full as Custom). Tray's service-restart row renamed Restart Punktfunk before the clients ship a machine-level Restart host. Docs: host-power page, access-levels row, WoL cross-link. openapi/header/SDK regenerated. --- api/openapi.json | 209 +++++++++++ .../io/unom/punktfunk/kit/SessionAccess.kt | 17 +- .../unom/punktfunk/kit/SessionAccessTest.kt | 6 +- .../Connection/PunktfunkConnection.swift | 22 +- crates/pf-client-core/src/access.rs | 33 +- crates/pf-client-core/src/trust.rs | 4 + crates/punktfunk-core/cbindgen.toml | 3 + crates/punktfunk-core/src/error.rs | 2 + crates/punktfunk-core/src/quic/access.rs | 55 ++- crates/punktfunk-core/src/reject.rs | 20 +- crates/punktfunk-host/Cargo.toml | 5 + crates/punktfunk-host/src/events.rs | 72 ++++ .../punktfunk-host/src/gamestream/control.rs | 12 +- crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/mgmt.rs | 6 +- crates/punktfunk-host/src/mgmt/actions.rs | 328 ++++++++++++++++++ crates/punktfunk-host/src/mgmt/auth.rs | 11 +- crates/punktfunk-host/src/mgmt/tests.rs | 91 +++++ crates/punktfunk-host/src/native.rs | 16 +- crates/punktfunk-host/src/native_pairing.rs | 5 +- .../src/native_pairing/store.rs | 4 +- crates/punktfunk-host/src/power.rs | 259 ++++++++++++++ crates/punktfunk-host/src/session_status.rs | 13 + crates/punktfunk-host/src/sleep_inhibit.rs | 9 + crates/punktfunk-tray/src/linux.rs | 5 +- crates/punktfunk-tray/src/win.rs | 7 +- docs-site/content/docs/access-levels.md | 3 +- docs-site/content/docs/host-power.md | 42 +++ docs-site/content/docs/meta.json | 1 + docs-site/content/docs/wake-on-lan.md | 6 +- docs-site/public/openapi.json | 209 +++++++++++ include/punktfunk_core.h | 22 +- sdk/src/gen/punktfunk.ts | 60 +++- web/messages/de.json | 10 + web/messages/en.json | 10 + web/server/routes/api/v1/actions/[id].post.ts | 22 ++ web/server/util/confirm.ts | 4 + web/src/sections/Host/PowerCard.tsx | 182 ++++++++++ web/src/sections/Host/index.tsx | 2 + web/src/sections/Host/view.tsx | 6 +- web/src/sections/Pairing/access.tsx | 48 ++- 41 files changed, 1795 insertions(+), 47 deletions(-) create mode 100644 crates/punktfunk-host/src/mgmt/actions.rs create mode 100644 crates/punktfunk-host/src/power.rs create mode 100644 docs-site/content/docs/host-power.md create mode 100644 web/server/routes/api/v1/actions/[id].post.ts create mode 100644 web/src/sections/Host/PowerCard.tsx diff --git a/api/openapi.json b/api/openapi.json index 924b89db1..5c70fdb97 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -13,6 +13,114 @@ "version": "0.32.0" }, "paths": { + "/api/v1/actions": { + "get": { + "tags": [ + "actions" + ], + "summary": "List host actions", + "description": "The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.", + "operationId": "listActions", + "responses": { + "200": { + "description": "The actions, per-caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionList" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/actions/{id}": { + "post": { + "tags": [ + "actions" + ], + "summary": "Invoke a host action", + "description": "Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.", + "operationId": "invokeAction", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted — sessions are being ended and the action follows in about a second" + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "This caller's access does not include this action (no Host power grant)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Unknown action id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "501": { + "description": "This host platform has no executor for it (macOS host)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/client-logs": { "get": { "tags": [ @@ -4331,6 +4439,67 @@ }, "components": { "schemas": { + "ActionInfo": { + "type": "object", + "description": "One action as the caller sees it (`GET /actions`).", + "required": [ + "id", + "title", + "group", + "danger", + "available", + "permitted" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)." + }, + "danger": { + "type": "boolean", + "description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)." + }, + "group": { + "type": "string", + "description": "Action group (`power` for the built-ins)." + }, + "id": { + "type": "string", + "description": "Stable action id (`power.sleep`, …) — the invoke path parameter.", + "example": "power.sleep" + }, + "permitted": { + "type": "boolean", + "description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)." + }, + "title": { + "type": "string", + "description": "Display title. Clients localize known ids and fall back to this for unknown ones." + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ], + "description": "Why it is unavailable, when it is." + } + } + }, + "ActionList": { + "type": "object", + "description": "`GET /actions` response.", + "required": [ + "actions" + ], + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionInfo" + } + } + } + }, "ActiveGame": { "type": "object", "description": "One launched game, for the console's running-game card.", @@ -6141,6 +6310,42 @@ } } }, + { + "type": "object", + "description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").", + "required": [ + "id", + "outcome", + "kind" + ], + "properties": { + "device": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceRef", + "description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)." + } + ] + }, + "id": { + "type": "string", + "description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)." + }, + "kind": { + "type": "string", + "enum": [ + "action.invoked" + ] + }, + "outcome": { + "type": "string", + "description": "`accepted`, or `failed: `." + } + } + }, { "type": "object", "required": [ @@ -9142,6 +9347,10 @@ { "name": "update", "description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)" + }, + { + "name": "actions", + "description": "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant" } ] } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionAccess.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionAccess.kt index 462e81884..b7315c6b2 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionAccess.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionAccess.kt @@ -29,14 +29,27 @@ object SessionAccess { /** Library launch (`Hello.launch`). */ const val LAUNCH = 1 shl 5 + /** Host power — the `power.*` host actions (`design/host-actions.md`); route-gated, never input. */ + const val POWER = 1 shl 6 + /** Every defined grant — full control, and what an old host's Welcome decodes to. */ - const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH + const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH or POWER + + /** `ALL` before POWER existed (hosts ≤ 0.32.x) — see [normalizeLegacyFull]. */ + private const val ALL_PRE_POWER = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH + + /** + * The legacy-full read rule (host-actions §4.3): exactly the pre-power full mask (an old + * host's "Full control") reads as the current [ALL], so it labels "Full control", not + * "Custom". Any other mask passes through. + */ + fun normalizeLegacyFull(grants: Int): Int = if (grants == ALL_PRE_POWER) ALL else grants /** * The preset name a mask displays as — §3.2's rule: three levels people actually reason * about, "Custom" for any other combination, never a raw bit list. */ - fun label(grants: Int): String = when (grants and ALL) { + fun label(grants: Int): String = when (normalizeLegacyFull(grants) and ALL) { ALL -> "Full control" GAMEPAD -> "Controller only" 0 -> "View only" diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/SessionAccessTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/SessionAccessTest.kt index 5bc5f4812..d27287da5 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/SessionAccessTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/SessionAccessTest.kt @@ -20,7 +20,8 @@ class SessionAccessTest { assertEquals(8, SessionAccess.CLIPBOARD) assertEquals(16, SessionAccess.MIC) assertEquals(32, SessionAccess.LAUNCH) - assertEquals(0x3F, SessionAccess.ALL) + assertEquals(64, SessionAccess.POWER) + assertEquals(0x7F, SessionAccess.ALL) } @Test @@ -35,6 +36,9 @@ class SessionAccessTest { SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD), ) assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv())) + // The legacy-full read rule (host-actions §4.3): an old host's pre-power "Full control" + // (exactly 0x3F) still labels Full, never Custom. + assertEquals("Full control", SessionAccess.label(0x3F)) } @Test diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 9563799cd..18292c5d7 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -556,9 +556,21 @@ public final class PunktfunkConnection { public static let grantClipboard: UInt32 = 1 << 3 public static let grantMic: UInt32 = 1 << 4 public static let grantLaunch: UInt32 = 1 << 5 + /// Host power — the `power.*` host actions (`design/host-actions.md`); route-gated on the + /// mgmt cert lane, never carried by any input event. + public static let grantPower: UInt32 = 1 << 6 /// Every defined grant — full control, today's behavior and what an old host's Welcome /// decodes to. - public static let grantAll: UInt32 = 0x3F + public static let grantAll: UInt32 = 0x7F + /// `grantAll` before Power existed (hosts ≤ 0.32.x) — see ``normalizedGrants(_:)``. + public static let grantAllPrePower: UInt32 = 0x3F + + /// The legacy-full read rule (host-actions §4.3): exactly the pre-power full mask — an old + /// host's "Full control" — reads as the current ``grantAll``, so a Full session against an + /// old host neither wears a chip nor labels "Custom". Any other mask passes through. + public static func normalizedGrants(_ grants: UInt32) -> UInt32 { + grants == grantAllPrePower ? grantAll : grants + } /// The three user-facing access presets plus "Custom", DERIVED from the mask (never /// stored — design §3.2, no drift). The label vocabulary is the cross-client one the web @@ -570,7 +582,7 @@ public final class PunktfunkConnection { case custom public init(grants: UInt32) { - switch grants & PunktfunkConnection.grantAll { + switch PunktfunkConnection.normalizedGrants(grants) & PunktfunkConnection.grantAll { case PunktfunkConnection.grantAll: self = .fullControl case PunktfunkConnection.grantGamepad: self = .controllerOnly case 0: self = .viewOnly @@ -631,9 +643,11 @@ public final class PunktfunkConnection { /// The session's grants allow mic injection — hide the mic UI without it. public var canUseMic: Bool { accessGrants & Self.grantMic != 0 } /// Anything about this session's access differs from the everyday full-and-permanent — - /// the chip's visibility gate: full + permanent must look exactly like today. + /// the chip's visibility gate: full + permanent must look exactly like today. Compared + /// through ``normalizedGrants(_:)`` so an old host's pre-power full mask stays chipless. public var accessIsLimited: Bool { - accessGrants & Self.grantAll != Self.grantAll || accessExpiresInSeconds != 0 + Self.normalizedGrants(accessGrants) & Self.grantAll != Self.grantAll + || accessExpiresInSeconds != 0 } /// The grant bit one wire input kind needs — the Swift mirror of core's exhaustive diff --git a/crates/pf-client-core/src/access.rs b/crates/pf-client-core/src/access.rs index 0b246d4c2..74c5c0a72 100644 --- a/crates/pf-client-core/src/access.rs +++ b/crates/pf-client-core/src/access.rs @@ -8,9 +8,20 @@ //! The Apple/Android clients mirror these rules rather than link them — the labels, the //! chip/notice wording and the derive-not-store rule below are the contract they copy. -use punktfunk_core::quic::{GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY}; +use punktfunk_core::quic::{ + normalize_legacy_full, GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY, +}; use std::time::{Duration, Instant}; +/// The mask as THIS build's vocabulary reads it: an old host's explicit pre-power "Full +/// control" normalizes to the current `GRANT_ALL` (the legacy-full rule, host-actions §4.3), +/// and bits newer than this build are dropped — so a new host's Full session never renders as +/// "Custom" on an older client (the #408-family rider fix: `preset_label` used to compare the +/// raw wire mask against `GRANT_ALL` unmasked). +fn effective_mask(grants: u32) -> u32 { + normalize_legacy_full(grants) & GRANT_ALL +} + /// What this session may do and for how long — the client-side snapshot of the host's /// [`Welcome`](punktfunk_core::quic::Welcome) advert, revised by every mid-session /// [`AccessUpdate`](punktfunk_core::quic::AccessUpdate) (latest wins). Carried on @@ -57,9 +68,11 @@ impl SessionAccess { } /// Full control, permanent — today's default look, which must stay unchanged: no chip, - /// no gating, no toasts (design §7; old-host degrade). + /// no gating, no toasts (design §7; old-host degrade). Compared through + /// [`effective_mask`], so neither an old host's pre-power full mask nor a future host's + /// wider one puts a chip on a session that is simply Full. pub fn is_default(&self) -> bool { - self.grants == GRANT_ALL && self.deadline.is_none() + effective_mask(self.grants) == GRANT_ALL && self.deadline.is_none() } /// Time left before this access expires — `None` = permanent, zero = already due @@ -83,9 +96,11 @@ impl SessionAccess { } /// The user-facing preset name DERIVED from the mask (design §3.2 — never stored, no -/// drift): the three presets, and "Custom" for any other combination. +/// drift): the three presets, and "Custom" for any other combination. Matches on +/// [`effective_mask`] so a host with a different grant vocabulary (older: pre-power full; +/// newer: bits this build doesn't know) still labels a Full session "Full control". pub fn preset_label(grants: u32) -> &'static str { - match grants { + match effective_mask(grants) { GRANT_ALL => "Full control", GRANT_PRESET_CONTROLLER_ONLY => "Controller only", GRANT_PRESET_VIEW_ONLY => "View only", @@ -135,6 +150,14 @@ mod tests { // the media-remote example, and a full mask missing one bit. assert_eq!(preset_label(GRANT_GAMEPAD | GRANT_CLIPBOARD), "Custom"); assert_eq!(preset_label(GRANT_ALL & !GRANT_KEYBOARD), "Custom"); + // The two vocabulary-drift cases (host-actions §4.3): an old host's pre-power full + // mask, and a future host's full mask with a bit this build doesn't know — both are + // simply Full, never "Custom". + assert_eq!( + preset_label(punktfunk_core::quic::GRANT_ALL_PRE_POWER), + "Full control" + ); + assert_eq!(preset_label(GRANT_ALL | (1 << 20)), "Full control"); } #[test] diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 3e210911f..9ae4c593f 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -876,6 +876,10 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S a game, or ask the host's owner to allow launching." .into() } + R::HostPower => { + "The host is going to sleep or shutting down — wake it when you want to play again." + .into() + } } } diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 758fbc3d0..1d371d94f 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -168,12 +168,14 @@ include = ["PunktfunkEndReason"] "FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE" "FLAG_SOF" = "PUNKTFUNK_FLAG_SOF" "GRANT_ALL" = "PUNKTFUNK_GRANT_ALL" +"GRANT_ALL_PRE_POWER" = "PUNKTFUNK_GRANT_ALL_PRE_POWER" "GRANT_CLIPBOARD" = "PUNKTFUNK_GRANT_CLIPBOARD" "GRANT_GAMEPAD" = "PUNKTFUNK_GRANT_GAMEPAD" "GRANT_KEYBOARD" = "PUNKTFUNK_GRANT_KEYBOARD" "GRANT_LAUNCH" = "PUNKTFUNK_GRANT_LAUNCH" "GRANT_MIC" = "PUNKTFUNK_GRANT_MIC" "GRANT_POINTER" = "PUNKTFUNK_GRANT_POINTER" +"GRANT_POWER" = "PUNKTFUNK_GRANT_POWER" "GRANT_PRESET_CONTROLLER_ONLY" = "PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY" "GRANT_PRESET_FULL" = "PUNKTFUNK_GRANT_PRESET_FULL" "GRANT_PRESET_VIEW_ONLY" = "PUNKTFUNK_GRANT_PRESET_VIEW_ONLY" @@ -197,6 +199,7 @@ include = ["PunktfunkEndReason"] "INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG" "INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC" "INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN" +"HOST_POWER_CLOSE_CODE" = "PUNKTFUNK_HOST_POWER_CLOSE_CODE" "LAUNCH_NOT_PERMITTED_CLOSE_CODE" = "PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE" "LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS" "MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES" diff --git a/crates/punktfunk-core/src/error.rs b/crates/punktfunk-core/src/error.rs index 259c7fe00..0823408c9 100644 --- a/crates/punktfunk-core/src/error.rs +++ b/crates/punktfunk-core/src/error.rs @@ -64,6 +64,7 @@ pub enum PunktfunkStatus { RejectedSetupFailed = -29, RejectedAccessExpired = -30, RejectedLaunchNotPermitted = -31, + RejectedHostPower = -32, Panic = -99, } @@ -95,6 +96,7 @@ impl PunktfunkError { R::SetupFailed => PunktfunkStatus::RejectedSetupFailed, R::AccessExpired => PunktfunkStatus::RejectedAccessExpired, R::LaunchNotPermitted => PunktfunkStatus::RejectedLaunchNotPermitted, + R::HostPower => PunktfunkStatus::RejectedHostPower, } } } diff --git a/crates/punktfunk-core/src/quic/access.rs b/crates/punktfunk-core/src/quic/access.rs index 45476c012..d532b9b45 100644 --- a/crates/punktfunk-core/src/quic/access.rs +++ b/crates/punktfunk-core/src/quic/access.rs @@ -32,13 +32,43 @@ pub const GRANT_CLIPBOARD: u32 = 1 << 3; pub const GRANT_MIC: u32 = 1 << 4; /// Library launch: `Hello.launch` resolution (and any future in-session launch/end verbs). pub const GRANT_LAUNCH: u32 = 1 << 5; +/// Host power: invoking the `power.*` host actions (sleep/reboot/shutdown) over the mgmt cert +/// lane (`design/host-actions.md` §4). Route-gated like `CLIPBOARD`/`MIC`/`LAUNCH` — no +/// datagram ever carries it, so [`classify`] is untouched. Machine power ONLY: future +/// plugin/custom actions get their own class, never this bit. +pub const GRANT_POWER: u32 = 1 << 6; /// Every defined grant. Also the value an *absent* mask means — a record from before grants /// existed (or an old host's Welcome that omits the field) is full control, so existing /// pairings keep today's behavior. -pub const GRANT_ALL: u32 = +pub const GRANT_ALL: u32 = GRANT_GAMEPAD + | GRANT_POINTER + | GRANT_KEYBOARD + | GRANT_CLIPBOARD + | GRANT_MIC + | GRANT_LAUNCH + | GRANT_POWER; + +/// [`GRANT_ALL`] as it was before [`GRANT_POWER`] existed (hosts ≤ 0.32.x) — the mask an +/// explicitly saved "Full control" wrote back then. See [`normalize_legacy_full`]. +pub const GRANT_ALL_PRE_POWER: u32 = GRANT_GAMEPAD | GRANT_POINTER | GRANT_KEYBOARD | GRANT_CLIPBOARD | GRANT_MIC | GRANT_LAUNCH; +/// The legacy-full read rule (`design/host-actions.md` §4.3): a mask that is EXACTLY the +/// pre-power [`GRANT_ALL_PRE_POWER`] was written by "Full control" before the Power bit existed +/// — read it as the current [`GRANT_ALL`], so an old explicit-Full record neither renders as +/// "Custom" nor silently lacks Power while looking Full. Not an escalation: that mask holds +/// `KEYBOARD`+`POINTER`, which already reach the streamed desktop's power menu (§4.2). The +/// deliberate consequence: "everything except Power" is not an expressible stored mask — by +/// design, because it would be a lock painted on an open door. Any other mask passes through. +pub fn normalize_legacy_full(mask: u32) -> u32 { + if mask == GRANT_ALL_PRE_POWER { + GRANT_ALL + } else { + mask + } +} + /// The reserved-must-be-zero region: a mask with any of these bits set is invalid today and is /// rejected at the management API (never silently cleared — the caller meant *something* this /// host doesn't understand, and clearing would grant less than they asked for without saying so). @@ -64,6 +94,8 @@ pub enum GrantClass { Clipboard, Mic, Launch, + /// The `power.*` host actions (route-gated on the mgmt cert lane; never an input event). + Power, } impl GrantClass { @@ -77,6 +109,7 @@ impl GrantClass { Self::Clipboard => GRANT_CLIPBOARD, Self::Mic => GRANT_MIC, Self::Launch => GRANT_LAUNCH, + Self::Power => GRANT_POWER, } } } @@ -124,6 +157,7 @@ mod tests { GRANT_CLIPBOARD, GRANT_MIC, GRANT_LAUNCH, + GRANT_POWER, ]; let mut acc = 0u32; for b in bits { @@ -138,13 +172,29 @@ mod tests { #[test] fn presets_match_the_design() { - // Full = everything; Controller-only = pad bit ONLY (no LAUNCH — §11 D2); View = nothing. + // Full = everything (Power included — host-actions §4.2); Controller-only = pad bit + // ONLY (no LAUNCH — §11 D2, and certainly no POWER); View = nothing. assert_eq!(GRANT_PRESET_FULL, GRANT_ALL); + assert_eq!(GRANT_PRESET_FULL & GRANT_POWER, GRANT_POWER); assert_eq!(GRANT_PRESET_CONTROLLER_ONLY, GRANT_GAMEPAD); assert_eq!(GRANT_PRESET_CONTROLLER_ONLY & GRANT_LAUNCH, 0); assert_eq!(GRANT_PRESET_VIEW_ONLY, 0); } + #[test] + fn legacy_full_reads_as_the_current_full() { + // Exactly the pre-power full mask (an explicitly saved "Full control" from ≤ 0.32.x) + // normalizes to today's GRANT_ALL — anything else, limited or already-current, passes + // through untouched (host-actions §4.3). + assert_eq!(GRANT_ALL_PRE_POWER, 0x3F); + assert_eq!(normalize_legacy_full(GRANT_ALL_PRE_POWER), GRANT_ALL); + assert_eq!(normalize_legacy_full(GRANT_ALL), GRANT_ALL); + assert_eq!(normalize_legacy_full(GRANT_GAMEPAD), GRANT_GAMEPAD); + assert_eq!(normalize_legacy_full(0), 0); + let limited = GRANT_ALL_PRE_POWER & !GRANT_KEYBOARD; + assert_eq!(normalize_legacy_full(limited), limited); + } + #[test] fn every_input_kind_classifies_per_the_design_table() { use GrantClass::*; @@ -181,5 +231,6 @@ mod tests { assert_eq!(GrantClass::Clipboard.bit(), GRANT_CLIPBOARD); assert_eq!(GrantClass::Mic.bit(), GRANT_MIC); assert_eq!(GrantClass::Launch.bit(), GRANT_LAUNCH); + assert_eq!(GrantClass::Power.bit(), GRANT_POWER); } } diff --git a/crates/punktfunk-core/src/reject.rs b/crates/punktfunk-core/src/reject.rs index 900d86276..e46db2943 100644 --- a/crates/punktfunk-core/src/reject.rs +++ b/crates/punktfunk-core/src/reject.rs @@ -48,6 +48,10 @@ pub const ACCESS_EXPIRED_CLOSE_CODE: u32 = 0x69; /// Refused AT the handshake — a crisp typed reason beats silently dropping the user onto a /// bare desktop they didn't ask for. Connecting *without* a launch request still works. pub const LAUNCH_NOT_PERMITTED_CLOSE_CODE: u32 = 0x6A; +/// A host power action (`power.sleep`/`reboot`/`shutdown`, `design/host-actions.md`) is ending +/// every session: the host is going to sleep or shutting down, deliberately — not a crash, not +/// the network. Old clients render the generic close; acceptable degrade. +pub const HOST_POWER_CLOSE_CODE: u32 = 0x6B; /// Why a host turned a connection away, decoded from the QUIC application close code — the /// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus @@ -81,6 +85,8 @@ pub enum RejectReason { AccessExpired, /// This device's grants don't include launching games (the `LAUNCH` bit is clear). LaunchNotPermitted, + /// The host is going to sleep or shutting down (a host power action ended the session). + HostPower, } impl RejectReason { @@ -100,6 +106,7 @@ impl RejectReason { SETUP_FAILED_CLOSE_CODE => Self::SetupFailed, ACCESS_EXPIRED_CLOSE_CODE => Self::AccessExpired, LAUNCH_NOT_PERMITTED_CLOSE_CODE => Self::LaunchNotPermitted, + HOST_POWER_CLOSE_CODE => Self::HostPower, _ => return None, }) } @@ -119,6 +126,7 @@ impl RejectReason { Self::SetupFailed => SETUP_FAILED_CLOSE_CODE, Self::AccessExpired => ACCESS_EXPIRED_CLOSE_CODE, Self::LaunchNotPermitted => LAUNCH_NOT_PERMITTED_CLOSE_CODE, + Self::HostPower => HOST_POWER_CLOSE_CODE, } } @@ -138,6 +146,7 @@ impl RejectReason { Self::SetupFailed => "setup-failed", Self::AccessExpired => "access-expired", Self::LaunchNotPermitted => "launch-not-permitted", + Self::HostPower => "host-power", } } } @@ -159,6 +168,7 @@ impl std::fmt::Display for RejectReason { Self::SetupFailed => "the host could not start the stream session", Self::AccessExpired => "your access to this host has expired", Self::LaunchNotPermitted => "this device is not permitted to launch games on the host", + Self::HostPower => "the host is going to sleep or shutting down", }) } } @@ -167,7 +177,7 @@ impl std::fmt::Display for RejectReason { mod tests { use super::*; - const ALL: [RejectReason; 12] = [ + const ALL: [RejectReason; 13] = [ RejectReason::PairingNotArmed, RejectReason::PairingBoundToOtherDevice, RejectReason::PairingRateLimited, @@ -180,6 +190,7 @@ mod tests { RejectReason::SetupFailed, RejectReason::AccessExpired, RejectReason::LaunchNotPermitted, + RejectReason::HostPower, ]; #[test] @@ -200,9 +211,10 @@ mod tests { #[test] fn foreign_codes_stay_untyped() { // Bare closes, the client's own pair-done codes, and the deliberate-end codes must - // never read as a host rejection. (0x69/0x6A left this list when they became the - // access-expired / launch-not-permitted codes; 0x6B is the block's next free id.) - for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6B, 0x70, u32::MAX] { + // never read as a host rejection. (0x69/0x6A/0x6B left this list when they became the + // access-expired / launch-not-permitted / host-power codes; 0x6C is the block's next + // free id.) + for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6C, 0x70, u32::MAX] { assert_eq!(RejectReason::from_close_code(code), None); } } diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index a95634c94..d0bc39012 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -307,6 +307,11 @@ windows = { version = "0.62", features = [ # CreateToolhelp32Snapshot/Process32*W — the conflicting-streaming-host process scan # (src/detect/windows.rs): is Sunshine/Apollo/... running alongside us? "Win32_System_Diagnostics_ToolHelp", + # The host power actions (src/power.rs, design/host-actions.md): SetSuspendState / + # IsPwrSuspendAllowed (Power) and InitiateSystemShutdownExW + the SeShutdownPrivilege + # constants (Shutdown). + "Win32_System_Power", + "Win32_System_Shutdown", ] } # The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs index 624296749..b4d779689 100644 --- a/crates/punktfunk-host/src/events.rs +++ b/crates/punktfunk-host/src/events.rs @@ -258,6 +258,21 @@ pub enum EventKind { /// `GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer /// is a join over several sources of truth, so "go look again" is the only honest signal. StoreChanged, + /// A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs. + /// Emitted on ACCEPT (`outcome: "accepted"`), and again if the executor later fails + /// (`outcome: "failed: …"`) — a succeeded power action ends this process, so "accepted with + /// no failure after it" is the success signal a hook can act on ("the host is going down"). + #[serde(rename = "action.invoked")] + ActionInvoked { + /// The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`). + id: String, + /// The invoking paired device, when the cert lane invoked it; absent for the + /// operator's console (admin lane). + #[serde(skip_serializing_if = "Option::is_none")] + device: Option, + /// `accepted`, or `failed: `. + outcome: String, + }, #[serde(rename = "host.started")] HostStarted { version: String, @@ -293,6 +308,7 @@ impl EventKind { EventKind::UpdateApplied { .. } => "update.applied", EventKind::PluginsChanged { .. } => "plugins.changed", EventKind::StoreChanged => "store.changed", + EventKind::ActionInvoked { .. } => "action.invoked", EventKind::HostStarted { .. } => "host.started", EventKind::HostStopping => "host.stopping", } @@ -322,6 +338,7 @@ impl EventKind { | EventKind::AccessGranted { device, .. } | EventKind::AccessChanged { device, .. } | EventKind::AccessExpired { device } => Some(&device.name), + EventKind::ActionInvoked { device, .. } => device.as_ref().map(|d| d.name.as_str()), _ => None, } } @@ -337,6 +354,9 @@ impl EventKind { | EventKind::AccessGranted { device, .. } | EventKind::AccessChanged { device, .. } | EventKind::AccessExpired { device } => Some(&device.fingerprint), + EventKind::ActionInvoked { device, .. } => { + device.as_ref().map(|d| d.fingerprint.as_str()) + } _ => None, } } @@ -723,6 +743,58 @@ mod tests { assert_eq!(expired.plane(), Some(Plane::Native)); } + /// The `action.invoked` wire shape (host actions design §3.3): a cert-lane invoke carries the + /// device; the console's (admin) invoke omits the field entirely — the optional convention. + #[test] + fn action_invoked_wire_shapes_and_filters() { + let ev = HostEvent { + seq: 10, + ts_ms: 1_700_000_000_000, + schema: 1, + kind: EventKind::ActionInvoked { + id: "power.sleep".into(), + device: Some(DeviceRef { + name: "Living Room TV".into(), + fingerprint: "ab12".into(), + plane: Plane::Native, + }), + outcome: "accepted".into(), + }, + }; + assert_eq!( + serde_json::to_string(&ev).unwrap(), + r#"{"seq":10,"ts_ms":1700000000000,"schema":1,"kind":"action.invoked","id":"power.sleep","device":{"name":"Living Room TV","fingerprint":"ab12","plane":"native"},"outcome":"accepted"}"# + ); + let admin = EventKind::ActionInvoked { + id: "power.shutdown".into(), + device: None, + outcome: "accepted".into(), + }; + assert_eq!( + serde_json::to_string(&admin).unwrap(), + r#"{"kind":"action.invoked","id":"power.shutdown","outcome":"accepted"}"# + ); + assert_eq!(admin.name(), "action.invoked"); + assert!(kind_matches("action.*", admin.name())); + assert_eq!(admin.client_name(), None); + let cert = HostEvent { + seq: 11, + ts_ms: 0, + schema: 1, + kind: EventKind::ActionInvoked { + id: "power.sleep".into(), + device: Some(DeviceRef { + name: "Guest Deck".into(), + fingerprint: "ab12".into(), + plane: Plane::Native, + }), + outcome: "accepted".into(), + }, + }; + assert_eq!(cert.kind.client_name(), Some("Guest Deck")); + assert_eq!(cert.kind.fingerprint(), Some("ab12")); + } + /// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a /// filterable event nobody can select is not a feature. #[test] diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index 3396e94f1..2b76e57b1 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -158,15 +158,17 @@ impl SessionAccess { /// whole session (per-event logging is the DoS), totals surfaced once at session end. Plain /// integers, not atomics: the control thread is the only writer and reader. struct GrantDrops { - counts: [u64; 6], - warned: [bool; 6], + // One slot per grant BIT (7 with `Power`), indexed by bit position — Power never produces + // input drops, but `idx` must stay in bounds for every `GrantClass`. + counts: [u64; 7], + warned: [bool; 7], } impl GrantDrops { fn new() -> GrantDrops { GrantDrops { - counts: [0; 6], - warned: [false; 6], + counts: [0; 7], + warned: [false; 7], } } @@ -1424,7 +1426,7 @@ mod tests { assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Gamepad)], 0); // Session end logs totals once and resets for the next session. drops.end_of_session(); - assert_eq!(drops.counts, [0u64; 6]); + assert_eq!(drops.counts, [0u64; 7]); } /// The session's live access state (WP13): a fingerprint with NO grants record is diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index cfcb5854f..3133baec6 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -101,6 +101,7 @@ mod native_pairing; mod osinfo; mod pipeline; mod plugins; +mod power; // Finding a launched game's processes from its store's detect signals — the read side of the // session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a // platform with neither (macOS, which has no launch path either) the module is an empty shell. diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index dcddad956..d6c295233 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -29,6 +29,7 @@ use utoipa::{Modify, OpenApi}; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_scalar::{Scalar, Servable}; +mod actions; mod auth; mod client_logs; mod clients; @@ -411,7 +412,9 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(store::get_runtime, store::set_runtime)) .routes(routes!(update::get_update_status)) .routes(routes!(update::force_update_check)) - .routes(routes!(update::apply_update)); + .routes(routes!(update::apply_update)) + .routes(routes!(actions::list_actions)) + .routes(routes!(actions::invoke_action)); OpenApiRouter::with_openapi(ApiDoc::openapi()) .nest("/api/v1", api_v1) .split_for_parts() @@ -454,6 +457,7 @@ pub fn openapi_json() -> String { (name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"), (name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"), (name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"), + (name = "actions", description = "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant"), ) )] struct ApiDoc; diff --git a/crates/punktfunk-host/src/mgmt/actions.rs b/crates/punktfunk-host/src/mgmt/actions.rs new file mode 100644 index 000000000..6671bf689 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/actions.rs @@ -0,0 +1,328 @@ +//! `/api/v1/actions` — the host-action registry (`design/host-actions.md`): discovery of the +//! actions this host offers *as seen by the caller*, and the id-only invoke. v1 ships the three +//! `power.*` built-ins; future host- or plugin-provided actions reuse these two routes, so +//! clients that render the discovery generically need no release to pick them up. +//! +//! Lane split (see `auth`): the **admin bearer** reaches both routes with everything permitted +//! (the console is the owner surface); a **paired streaming cert** reaches both too — the +//! lane's third write route after `POST /client-logs` and the whole point of the design +//! ("Sleep host" from the couch, out of session) — but invoke demands the `GRANT_POWER` bit, +//! re-read via `effective(fp, now)` PER REQUEST so console edits, expiry and unpair apply to +//! the very next call. The **plugin token** gets neither route: a plugin that wants to +//! power-manage the host is an operator-hook story, not a shared-token capability. +//! +//! The invoke invariant (the `host-update` recipe): the request is a trigger — the id selects a +//! fixed host-side behavior, the body is empty, and **no request field ever reaches the +//! privileged path**. Ordering on accept: reply `202` → end every session (typed +//! `RejectReason::HostPower` close, which also drops our own sleep-inhibit hold) → ~1 s grace +//! so the reply flushes before the NIC goes away → act. + +use super::auth::AuthLane; +use super::shared::*; +use crate::gamestream::tls::PeerCertFingerprint; +use crate::power::PowerVerb; +use axum::Extension; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// One built-in action: a stable id (`.` — `plugin::` is reserved for +/// plugin-provided actions later) bound to a fixed executor. The registry is code on purpose: +/// no persistence, nothing a request can add to. +struct Builtin { + id: &'static str, + /// English display title — clients map KNOWN ids to their own localized strings and fall + /// back to this for ids they don't know yet. + title: &'static str, + /// Two-press confirm hint for client UIs (reboot/shutdown lose state; sleep is reversible). + danger: bool, + verb: PowerVerb, +} + +/// v1: the three machine-power verbs, all under the `power` group / `GRANT_POWER` bit. +const BUILTINS: [Builtin; 3] = [ + Builtin { + id: "power.sleep", + title: "Sleep host", + danger: false, + verb: PowerVerb::Sleep, + }, + Builtin { + id: "power.reboot", + title: "Restart host", + danger: true, + verb: PowerVerb::Reboot, + }, + Builtin { + id: "power.shutdown", + title: "Shut down host", + danger: true, + verb: PowerVerb::Shutdown, + }, +]; + +/// One action as the caller sees it (`GET /actions`). +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct ActionInfo { + /// Stable action id (`power.sleep`, …) — the invoke path parameter. + #[schema(example = "power.sleep")] + pub id: String, + /// Display title. Clients localize known ids and fall back to this for unknown ones. + pub title: String, + /// Action group (`power` for the built-ins). + pub group: String, + /// Whether a client UI should double-confirm (the action loses state — reboot/shutdown). + pub danger: bool, + /// Whether this host can run it right now (platform probe — a VM that can't S3 lists + /// sleep as unavailable rather than offering a dead switch). + pub available: bool, + /// Why it is unavailable, when it is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, + /// Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER` + /// bit of the device's live access mask). + pub permitted: bool, +} + +/// `GET /actions` response. +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct ActionList { + pub actions: Vec, +} + +/// Whether this caller may invoke the `power.*` actions: the admin bearer always; a paired +/// cert exactly when its live mask (re-read NOW — expiry- and edit-aware) carries +/// [`punktfunk_core::quic::GRANT_POWER`]. Any other lane: no. +fn power_permitted(st: &MgmtState, lane: AuthLane, fp: Option<&str>) -> bool { + match lane { + AuthLane::Admin => true, + AuthLane::Cert => fp.is_some_and(|fp| { + st.native + .as_ref() + .and_then(|n| n.effective(fp, unix_now())) + .is_some_and(|mask| mask & punktfunk_core::quic::GRANT_POWER != 0) + }), + AuthLane::Plugin | AuthLane::Public => false, + } +} + +/// Host wall clock, unix seconds (the clock the stored access deadlines are expressed in). +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// List host actions +/// +/// The actions this host offers, as seen by the caller: platform availability (with the honest +/// reason when something can't run) and whether THIS caller is permitted to invoke each one. +/// Admin lane: everything permitted. Paired-cert lane: permission follows the device's live +/// access mask (the Host power grant). Clients render rows generically — unknown ids still +/// work with the server-supplied title. +#[utoipa::path( + get, + path = "/actions", + tag = "actions", + operation_id = "listActions", + responses( + (status = OK, description = "The actions, per-caller", body = ActionList), + (status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError), + ) +)] +pub(crate) async fn list_actions( + State(st): State>, + Extension(lane): Extension, + fp: Option>, +) -> Json { + let fp = fp.as_ref().and_then(|e| e.0 .0.as_deref()); + let permitted = power_permitted(&st, lane, fp); + // The probes are D-Bus round trips on Linux — off the async worker, all three in one hop. + let probed = tokio::task::spawn_blocking(|| BUILTINS.map(|b| crate::power::probe(b.verb))) + .await + .expect("power probe task panicked"); + let actions = BUILTINS + .iter() + .zip(probed) + .map(|(b, avail)| ActionInfo { + id: b.id.into(), + title: b.title.into(), + group: "power".into(), + danger: b.danger, + available: crate::power::supported() && avail.available, + unavailable_reason: if crate::power::supported() { + avail.reason + } else { + Some("not supported on this host platform".into()) + }, + permitted, + }) + .collect(); + Json(ActionList { actions }) +} + +/// One action is in flight host-wide (`409 busy` otherwise) — the actions themselves end the +/// conversation, so this is all the rate limiting v1 needs. +static IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +/// Denials are logged once per (fingerprint, action) per boot — a retrying client must not turn +/// the host log into the DoS (the `GrantDrops` discipline). +fn log_denial_once(fp: &str, action: &str, device: &str) { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static LOGGED: OnceLock>> = OnceLock::new(); + let mut set = LOGGED + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + if set.insert((fp.to_string(), action.to_string())) { + tracing::info!( + device, + fingerprint = fp, + action, + "denied a host action — this device's access lacks the Host power grant \ + (further denials of this pair are silent this boot)" + ); + } +} + +/// Invoke a host action +/// +/// Runs one action by id — empty body, no parameters: the id selects a fixed host-side +/// behavior, and nothing in the request reaches the privileged path. On `202` the host first +/// ends every streaming session cleanly (clients see a typed "the host is going to sleep / +/// shutting down" close), waits ~1 s so this response flushes, then acts. +/// +/// Paired-cert callers need the **Host power** grant, and are refused (`409`) while another +/// device's session is live — a granted guest cannot yank the host out from under the owner +/// mid-stream. The admin console is never blocked (it warns instead). One action runs at a +/// time host-wide. +#[utoipa::path( + post, + path = "/actions/{id}", + tag = "actions", + operation_id = "invokeAction", + params(("id" = String, Path, description = "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)")), + responses( + (status = ACCEPTED, description = "Accepted — sessions are being ended and the action follows in about a second"), + (status = FORBIDDEN, description = "This caller's access does not include this action (no Host power grant)", body = ApiError), + (status = NOT_FOUND, description = "Unknown action id", body = ApiError), + (status = CONFLICT, description = "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)", body = ApiError), + (status = NOT_IMPLEMENTED, description = "This host platform has no executor for it (macOS host)", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError), + ) +)] +pub(crate) async fn invoke_action( + State(st): State>, + Extension(lane): Extension, + fp: Option>, + Path(id): Path, +) -> Response { + let Some(builtin) = BUILTINS.iter().find(|b| b.id == id) else { + return api_error(StatusCode::NOT_FOUND, "unknown action id"); + }; + let fp = fp.as_ref().and_then(|e| e.0 .0.as_deref()); + // The invoking device's roster identity (cert lane) — for the audit line and the event. + let device = fp.and_then(|fp| { + st.native.as_ref().and_then(|n| { + n.list() + .into_iter() + .find(|c| c.fingerprint.eq_ignore_ascii_case(fp)) + .map(|c| crate::events::DeviceRef { + name: c.name, + fingerprint: c.fingerprint, + plane: crate::events::Plane::Native, + }) + }) + }); + if !power_permitted(&st, lane, fp) { + let device_name = device.as_ref().map(|d| d.name.as_str()).unwrap_or(""); + log_denial_once(fp.unwrap_or(""), builtin.id, device_name); + return api_error( + StatusCode::FORBIDDEN, + "this device's access does not include host power — ask the host's operator to \ + enable the Host power grant", + ); + } + if !crate::power::supported() { + return api_error( + StatusCode::NOT_IMPLEMENTED, + "host power actions are not supported on this host platform", + ); + } + // Busy policy (design §5.5): another device's LIVE session blocks a cert-lane invoke — + // your own session doesn't (you know what you asked for), and the admin console is never + // blocked (it is the owner surface; the console warns before sending). A GameStream stream + // is always another device on this policy: its cert identity is never the native one. + if lane == AuthLane::Cert { + let others_native = crate::session_status::other_client_live(fp.unwrap_or("")); + let gamestream = st.app.streaming.load(Ordering::SeqCst); + if others_native || gamestream { + return api_error( + StatusCode::CONFLICT, + "blocked: another device is streaming from this host right now", + ); + } + } + let verb = builtin.verb; + let avail = tokio::task::spawn_blocking(move || crate::power::probe(verb)) + .await + .expect("power probe task panicked"); + if !avail.available { + return api_error( + StatusCode::CONFLICT, + &format!( + "blocked: {}", + avail.reason.as_deref().unwrap_or("the platform said no") + ), + ); + } + if IN_FLIGHT.swap(true, Ordering::SeqCst) { + return api_error( + StatusCode::CONFLICT, + "a host action is already in flight — the host is on its way down", + ); + } + let invoker = device + .as_ref() + .map(|d| d.name.clone()) + .unwrap_or_else(|| "the host console".into()); + tracing::info!(action = builtin.id, invoked_by = %invoker, "host action accepted"); + crate::events::emit(crate::events::EventKind::ActionInvoked { + id: builtin.id.into(), + device: device.clone(), + outcome: "accepted".into(), + }); + // Reply first, act after: the 202 must flush before the NIC goes away. The typed close + // fires now so every paired session ends as "the host is going to sleep", the quit-flavored + // stop catches the rest (anonymous sessions, belt for the rest), and the compat plane's + // teardown runs its own path. + let id_owned: String = builtin.id.into(); + let app = st.app.clone(); + tokio::spawn(async move { + crate::power::set_closing(true); + crate::session_status::stop_all_quit(); + let _ = app.quit_session("host power action"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Our own suspend veto must be gone before logind is asked (we never hold + // -ignore-inhibit rights): synchronous belt over the session-teardown braces. + crate::sleep_inhibit::release_now(); + let outcome = tokio::task::spawn_blocking(move || crate::power::act(verb)) + .await + .unwrap_or_else(|e| Err(format!("executor task panicked: {e}"))); + match outcome { + // A reboot/shutdown ends this process shortly; a sleep resumes here on wake. + Ok(()) => tracing::info!(action = %id_owned, "host power action handed to the OS"), + Err(e) => { + tracing::warn!(action = %id_owned, error = %e, "host power action FAILED"); + crate::events::emit(crate::events::EventKind::ActionInvoked { + id: id_owned, + device, + outcome: format!("failed: {e}"), + }); + } + } + crate::power::set_closing(false); + IN_FLIGHT.store(false, Ordering::SeqCst); + }); + StatusCode::ACCEPTED.into_response() +} diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index c3ca0cedc..15151ded0 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -313,19 +313,28 @@ fn path_matches(pattern: &str, path: &str) -> bool { /// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions, /// edit the library). `/health` is handled separately (always open). pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool { - // The ONE write on this lane: a paired device uploading its own log bundle for the operator + // The FIRST write on this lane: a paired device uploading its own log bundle for the operator // ("send logs to host" — the only way logs escape a Deck in Gaming Mode or a tvOS box). // Deliberately write-only: the device gets an id back and can read NOTHING — not the bundle // list, not even its own upload. Size- and quota-capped in the handler/store. if method == Method::POST && path == "/api/v1/client-logs" { return true; } + // The lane's SECOND write: invoking a host action (`design/host-actions.md` §5.2) — power + // is most useful OUT of session ("sleep the host" from the host tile), which is exactly + // this lane. Id-only, empty body, and the handler re-reads `effective(fp, now)` and demands + // the `GRANT_POWER` bit per request; the route being reachable grants nothing by itself. + // Discovery (`GET /actions`) rides the read list below, per-caller-filtered in the handler. + if method == Method::POST && path_matches("/api/v1/actions/{}", path) { + return true; + } method == Method::GET && (matches!( path, "/api/v1/host" | "/api/v1/compositors" | "/api/v1/status" + | "/api/v1/actions" // The paired-client ROSTERS (`/clients`, `/native/clients`) are deliberately NOT on // this lane — they expose every OTHER paired device's name + fingerprint, which one // paired streaming client must not be able to enumerate. Only the bearer/loopback diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index c3039366d..dee9fd7f7 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -159,6 +159,91 @@ async fn send_cert(app: &Router, mut req: axum::http::Request, fp: &str) - app.clone().oneshot(req).await.expect("infallible").status() } +/// The host-actions surface (design/host-actions.md): discovery reports `permitted` per caller +/// from the device's LIVE mask; invoke 403s without the Power grant and 404s an unknown id. +/// An explicitly stored pre-power "Full control" (`0x3F`) carries Power via the legacy-full +/// read rule (§4.3). Deliberately NO test ever reaches a 202 accept — on a real box that +/// would genuinely suspend it. +#[tokio::test] +async fn host_actions_follow_the_power_grant() { + use punktfunk_core::quic::{GRANT_ALL_PRE_POWER, GRANT_GAMEPAD}; + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some(std::env::temp_dir().join(format!("pf-mgmt-actions-{}.json", std::process::id()))), + None, + false, + ) + .unwrap(), + ); + let guest_fp = "aaaa00000001"; + let owner_fp = "bbbb00000002"; + let legacy_fp = "cccc00000003"; + np.add_with_access( + "guest", + guest_fp, + Some(crate::native_pairing::Access { + grants: GRANT_GAMEPAD, + expires_unix: None, + }), + ) + .unwrap(); + np.add("owner", owner_fp).unwrap(); // absent grants = full control, Power included + np.add_with_access( + "legacy", + legacy_fp, + Some(crate::native_pairing::Access { + grants: GRANT_ALL_PRE_POWER, // an explicit pre-power "Full control" + expires_unix: None, + }), + ) + .unwrap(); + let app = test_app_native(test_state(), np); + + let discover = |fp: &str| { + let mut req = get_req("/api/v1/actions"); + req.extensions_mut() + .insert(PeerCertFingerprint(Some(fp.to_string()))); + req + }; + let (status, body) = send(&app, discover(guest_fp)).await; + assert_eq!(status, StatusCode::OK); + let rows = body["actions"].as_array().unwrap(); + assert_eq!(rows.len(), 3, "{body}"); + assert!( + rows.iter().all(|a| a["permitted"] == false), + "a controller-only guest must not be offered power: {body}" + ); + for fp in [owner_fp, legacy_fp] { + let (_, body) = send(&app, discover(fp)).await; + assert!( + body["actions"] + .as_array() + .unwrap() + .iter() + .all(|a| a["permitted"] == true), + "full control (current or legacy-stored) carries Power: {body}" + ); + } + // The admin bearer (no cert) sees everything permitted — the console is the owner surface. + let (_, body) = send(&app, get_req("/api/v1/actions")).await; + assert!(body["actions"] + .as_array() + .unwrap() + .iter() + .all(|a| a["permitted"] == true)); + + let post = |path: &str| axum::http::Request::post(path).body(Body::empty()).unwrap(); + // Invoke without the grant: the typed 403, distinct from unpaired (which never gets here). + assert_eq!( + send_cert(&app, post("/api/v1/actions/power.sleep"), guest_fp).await, + StatusCode::FORBIDDEN, + "no Power bit ⇒ 403" + ); + // Unknown id: 404, before any permission or platform question. + let (status, _) = send(&app, post("/api/v1/actions/no.such")).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + /// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every /// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4). #[tokio::test] @@ -1650,6 +1735,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { ("GET", "/api/v1/update/status", false, false), ("POST", "/api/v1/update/check", false, false), ("POST", "/api/v1/update/apply", false, false), + // ---- host actions (design/host-actions.md): the cert lane's surface — discovery is + // per-caller-filtered, invoke demands the GRANT_POWER bit in the handler. The plugin + // token gets NEITHER route: a plugin that wants to power-manage the host is an + // operator-hook/automation story, not a shared-token capability (§3.4). + ("GET", "/api/v1/actions", false, true), + ("POST", "/api/v1/actions/{id}", false, true), ]; /// A path template's concrete form: every `{param}` segment becomes a literal, so the gates diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 8d9151967..707829576 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -706,8 +706,10 @@ fn close_rejected(conn: &quinn::Connection, reason: punktfunk_core::reject::Reje /// malicious client must not turn the log into the DoS — with the totals surfaced once in the /// datagram loop's end-of-stream line. struct GrantDrops { - counts: [AtomicU64; 6], - warned: [AtomicBool; 6], + // One slot per grant BIT (7 with `Power`), indexed by bit position — Power never produces + // input drops, but `idx` must stay in bounds for every `GrantClass`. + counts: [AtomicU64; 7], + warned: [AtomicBool; 7], } impl GrantDrops { @@ -817,6 +819,10 @@ async fn access_lifecycle( device: crate::events::DeviceRef, ) { let mut warned = spent_warnings(deadline, wall_unix_now()); + // The host-power signal (`design/host-actions.md` §5.8): a `power.*` action ending every + // session closes THIS connection with the typed code, so the client says "the host is + // going to sleep" instead of a bare transport error. + let mut power_rx = crate::power::closing_rx(); loop { let now = wall_unix_now(); if let Some(d) = deadline { @@ -885,6 +891,12 @@ async fn access_lifecycle( }); } } + changed = power_rx.changed() => { + if changed.is_ok() && *power_rx.borrow_and_update() { + close_rejected(&conn, punktfunk_core::reject::RejectReason::HostPower); + return; + } + } _ = conn.closed() => return, // session over — nothing left to guard } } diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index db5f2a7b0..b8eaaa5c4 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -223,7 +223,10 @@ impl NativePairing { if c.expires_unix.is_some_and(|t| now_unix >= t) { None } else { - Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL) + Some( + punktfunk_core::quic::normalize_legacy_full(c.grants.unwrap_or(GRANT_ALL)) + & GRANT_ALL, + ) } } } diff --git a/crates/punktfunk-host/src/native_pairing/store.rs b/crates/punktfunk-host/src/native_pairing/store.rs index 35e1f573a..09f34764e 100644 --- a/crates/punktfunk-host/src/native_pairing/store.rs +++ b/crates/punktfunk-host/src/native_pairing/store.rs @@ -126,6 +126,8 @@ impl TrustStore { /// expired, `Some(mask)` otherwise (absent grants = [`GRANT_ALL`], the pre-grants record). /// The mask is ANDed with [`GRANT_ALL`] on the way out: a store written by a future host /// version (or hand-edited) can't smuggle reserved bits into this version's enforcement. + /// An explicitly stored pre-power "Full control" (exactly the old `GRANT_ALL`) reads as the + /// current one — the legacy-full rule, `normalize_legacy_full` (host-actions §4.3). /// `now_unix` is the caller's wall clock — passed in, not sampled here, so the expiry /// evaluation and whatever decision it feeds share one instant. pub(super) fn effective(&self, fp_hex: &str, now_unix: i64) -> Option { @@ -138,7 +140,7 @@ impl TrustStore { if c.expires_unix.is_some_and(|t| now_unix >= t) { return None; } - Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL) + Some(punktfunk_core::quic::normalize_legacy_full(c.grants.unwrap_or(GRANT_ALL)) & GRANT_ALL) } /// The stored record for a fingerprint (for the facade's watch-state snapshot and the diff --git a/crates/punktfunk-host/src/power.rs b/crates/punktfunk-host/src/power.rs new file mode 100644 index 000000000..abeffb14d --- /dev/null +++ b/crates/punktfunk-host/src/power.rs @@ -0,0 +1,259 @@ +//! Machine power executors for the `power.*` host actions (`design/host-actions.md` §6): +//! sleep / reboot / shutdown, plus the per-verb availability probe the discovery route reports. +//! +//! Linux drives logind over zbus — the SAME privileged path the already-shipped polkit rule +//! (`packaging/linux/49-punktfunk-power.rules`) authorizes for members of group `punktfunk`, +//! and deliberately WITHOUT `-ignore-inhibit`/`-multiple-sessions`: a foreign block inhibitor +//! or a second local user makes logind refuse, and that refusal is surfaced honestly as a +//! `409 blocked` instead of being steamrolled. Windows uses the interactive user token's own +//! `SeShutdownPrivilege` (`InitiateSystemShutdownExW` / `SetSuspendState`). macOS has no +//! executor yet (the mgmt route answers `501`). +//! +//! Both [`probe`] and [`act`] BLOCK (a D-Bus round trip / a Win32 call) — call them via +//! `spawn_blocking` from async contexts. The zbus threading dance mirrors +//! [`crate::sleep_inhibit::acquire`]: zbus's blocking API cannot run on a tokio worker. + +/// The three built-in machine-power verbs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PowerVerb { + Sleep, + Reboot, + Shutdown, +} + +/// One verb's platform answer: can this host do it right now, and if not, why (the honest +/// `unavailable_reason` the discovery route reports — the `SessionSettingsState::enforced` +/// pattern: say "unavailable, because X" instead of offering a dead switch). +pub struct Availability { + pub available: bool, + pub reason: Option, +} + +impl Availability { + fn yes() -> Availability { + Availability { + available: true, + reason: None, + } + } + + fn no(reason: impl Into) -> Availability { + Availability { + available: false, + reason: Some(reason.into()), + } + } +} + +/// Whether this platform has power executors at all — `false` answers the invoke route with +/// `501 unsupported` (macOS host, until that leg exists). +pub fn supported() -> bool { + cfg!(any(target_os = "linux", target_os = "windows")) +} + +// ---------------------------------------------------------------------------- Linux (logind) + +/// One logind `Manager` call on a dedicated plain thread (see the module header for why), the +/// reply deserialized as `T`. Errors come back as the D-Bus error text — which IS the honest +/// reason ("Interactive authentication required", "Operation inhibited by …"). +#[cfg(target_os = "linux")] +fn logind_call(method: &'static str, args: A) -> Result +where + T: for<'de> serde::Deserialize<'de> + ashpd::zbus::zvariant::Type + Send + 'static, + A: serde::Serialize + ashpd::zbus::zvariant::DynamicType + Send + Sync + 'static, +{ + std::thread::spawn(move || -> Result { + use ashpd::zbus; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { + let conn = zbus::Connection::system() + .await + .map_err(|e| e.to_string())?; + let reply = conn + .call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + method, + &args, + ) + .await + .map_err(|e| e.to_string())?; + reply.body().deserialize().map_err(|e| e.to_string()) + }) + }) + .join() + .map_err(|_| "logind call thread panicked".to_string())? +} + +/// Ask logind whether the verb can run: `CanSuspend`/`CanReboot`/`CanPowerOff` answer `"yes"`, +/// `"no"`, `"na"` (hardware can't) or `"challenge"` (polkit would need interactive auth — +/// typically the host user is not in group `punktfunk`, or a second local user is logged in). +/// Only `"yes"` is available; everything else carries its reason. +#[cfg(target_os = "linux")] +pub fn probe(verb: PowerVerb) -> Availability { + let method = match verb { + PowerVerb::Sleep => "CanSuspend", + PowerVerb::Reboot => "CanReboot", + PowerVerb::Shutdown => "CanPowerOff", + }; + match logind_call::(method, ()) { + Ok(ans) if ans == "yes" => Availability::yes(), + Ok(ans) if ans == "challenge" => Availability::no( + "the host would need interactive authorization — is the host user in group \ + 'punktfunk' (and no second local user logged in)?", + ), + Ok(ans) if ans == "na" => Availability::no("this machine does not support it"), + Ok(ans) => Availability::no(format!("logind answered {ans:?}")), + Err(e) => Availability::no(format!("no logind: {e}")), + } +} + +/// Run the verb: `Suspend`/`Reboot`/`PowerOff` with `interactive = false` — a polkit challenge +/// fails instead of prompting (there is nobody at a dialog on a streaming host). The caller has +/// already ended every session and released our own sleep inhibitor +/// ([`crate::sleep_inhibit::release_now`]) — a still-standing foreign inhibitor makes logind +/// refuse, and the error text says whose it is. +#[cfg(target_os = "linux")] +pub fn act(verb: PowerVerb) -> Result<(), String> { + let method = match verb { + PowerVerb::Sleep => "Suspend", + PowerVerb::Reboot => "Reboot", + PowerVerb::Shutdown => "PowerOff", + }; + logind_call::<(), (bool,)>(method, (false,)) +} + +// ------------------------------------------------------------------------------------ Windows + +/// Windows: reboot/shutdown are available whenever the interactive user token holds +/// `SeShutdownPrivilege` (it does by default — [`act`] enables and uses it); sleep asks the +/// power manager whether suspend is supported at all. +#[cfg(target_os = "windows")] +pub fn probe(verb: PowerVerb) -> Availability { + match verb { + PowerVerb::Sleep => { + // SAFETY: no arguments, no aliasing — a pure capability query. + if unsafe { windows::Win32::System::Power::IsPwrSuspendAllowed() } { + Availability::yes() + } else { + Availability::no("this machine does not support sleep") + } + } + PowerVerb::Reboot | PowerVerb::Shutdown => Availability::yes(), + } +} + +/// Enable this process's `SeShutdownPrivilege` (present-but-disabled by default on an +/// interactive user token), then run the verb. The reason string lands in the system event log. +#[cfg(target_os = "windows")] +pub fn act(verb: PowerVerb) -> Result<(), String> { + use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; + use windows::Win32::Security::{ + AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES, SE_PRIVILEGE_ENABLED, + SE_SHUTDOWN_NAME, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, + }; + use windows::Win32::System::Power::SetSuspendState; + use windows::Win32::System::Shutdown::{ + InitiateSystemShutdownExW, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_OTHER, + SHTDN_REASON_MINOR_OTHER, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + // SAFETY: standard privilege-enable sequence on our own process token; the token handle is + // closed on every path. + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut token, + ) + .map_err(|e| format!("OpenProcessToken: {e}"))?; + let mut luid = LUID::default(); + let looked_up = LookupPrivilegeValueW(None, SE_SHUTDOWN_NAME, &mut luid); + let adjusted = looked_up.and_then(|()| { + let privs = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + AdjustTokenPrivileges(token, false, Some(&raw const privs), 0, None, None) + }); + let _ = CloseHandle(token); + adjusted.map_err(|e| format!("enabling SeShutdownPrivilege: {e}"))?; + } + + match verb { + PowerVerb::Sleep => { + // SAFETY: plain suspend request — no hibernate, honor other apps' wake locks. + if unsafe { SetSuspendState(false, false, false) } { + Ok(()) + } else { + Err(format!( + "SetSuspendState failed: {}", + windows::core::Error::from_thread() + )) + } + } + PowerVerb::Reboot | PowerVerb::Shutdown => { + let reason = windows::core::HSTRING::from( + "Requested from a Punktfunk client (host power action)", + ); + // SAFETY: local machine (None), owned wide strings live across the call. + unsafe { + InitiateSystemShutdownExW( + None, + &reason, + 0, // no countdown dialog — sessions were already ended cleanly + true, // force apps closed; nobody is at the console to answer prompts + verb == PowerVerb::Reboot, + SHTDN_REASON_MAJOR_OTHER | SHTDN_REASON_MINOR_OTHER | SHTDN_REASON_FLAG_PLANNED, + ) + } + .map_err(|e| format!("InitiateSystemShutdownExW: {e}")) + } + } +} + +// -------------------------------------------------------------------------- other platforms + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +pub fn probe(_verb: PowerVerb) -> Availability { + Availability::no("not supported on this host platform") +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +pub fn act(_verb: PowerVerb) -> Result<(), String> { + Err("not supported on this host platform".into()) +} + +// ------------------------------------------------------------------- the session close signal + +/// The host-wide "a power action is ending every session" signal. Each paired session's +/// access-lifecycle task subscribes ([`closing_rx`]) and closes its connection with the typed +/// `RejectReason::HostPower` code when it fires — so the client renders "the host is going to +/// sleep" instead of a bare transport error. One-way: nothing un-fires it (sleep resumes with +/// no live sessions either way), but the flag resets after a completed sleep so the woken host +/// types future closes correctly. +static POWER_CLOSING: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +fn power_closing() -> &'static tokio::sync::watch::Sender { + POWER_CLOSING.get_or_init(|| tokio::sync::watch::channel(false).0) +} + +/// Subscribe to the power-close signal (one receiver per session lifecycle task). +pub fn closing_rx() -> tokio::sync::watch::Receiver { + power_closing().subscribe() +} + +/// Fire (or reset) the power-close signal. +pub fn set_closing(closing: bool) { + let _ = power_closing().send(closing); +} diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 2a1285c87..6035ad21c 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -356,6 +356,19 @@ pub fn stop_by_fingerprint(fp_hex: &str) -> usize { n } +/// Whether any live native session belongs to a client OTHER than `fp_hex` — the host-power +/// busy policy (`design/host-actions.md` §5.5): a granted guest must not pull the host out from +/// under someone else's live stream. Label matching as in [`stop_by_fingerprint`] (the +/// 12-hex-char fingerprint prefix); an anonymous, IP-labelled session always counts as another +/// client — it is certainly not the invoking paired device. +pub fn other_client_live(fp_hex: &str) -> bool { + registry() + .lock() + .unwrap() + .iter() + .any(|s| !(s.client.len() == 12 && fp_hex.starts_with(s.client.as_str()))) +} + pub fn stop_all_quit() { for s in registry().lock().unwrap().iter() { s.quit.store(true, Ordering::SeqCst); diff --git a/crates/punktfunk-host/src/sleep_inhibit.rs b/crates/punktfunk-host/src/sleep_inhibit.rs index 8af11f8e0..4859f3865 100644 --- a/crates/punktfunk-host/src/sleep_inhibit.rs +++ b/crates/punktfunk-host/src/sleep_inhibit.rs @@ -147,6 +147,15 @@ fn release(why: &str) { #[cfg(not(target_os = "linux"))] fn release(_why: &str) {} +/// Drop any standing veto RIGHT NOW — the host-power path (`design/host-actions.md` §6): an +/// explicit `power.sleep` must not be refused by our own block inhibitor (we deliberately never +/// hold `-ignore-inhibit` rights). The session teardown that precedes it drops the holds too, +/// but via the video loops' next stop-flag check — this is the synchronous belt so the +/// `Suspend()` call can never race a veto that is already on its way out. +pub fn release_now() { + release("a host power action is suspending/stopping this machine"); +} + #[cfg(target_os = "linux")] fn release_locked(st: &mut State, why: &str) { if st.fd.take().is_some() { diff --git a/crates/punktfunk-tray/src/linux.rs b/crates/punktfunk-tray/src/linux.rs index 9d05215c2..99b330aec 100644 --- a/crates/punktfunk-tray/src/linux.rs +++ b/crates/punktfunk-tray/src/linux.rs @@ -155,7 +155,10 @@ impl ksni::Tray for HostTray { } .into(), StandardItem { - label: "Restart host".into(), + // "Restart Punktfunk", not "Restart host": this restarts the SERVICE, and the + // clients' host-power menus use "Restart host" for the MACHINE + // (design/host-actions.md §7) — one phrase must not mean two verbs. + label: "Restart Punktfunk".into(), visible: running || matches!(self.status, TrayStatus::Error(_)), activate: Box::new(|t: &mut Self| t.systemctl("restart")), ..Default::default() diff --git a/crates/punktfunk-tray/src/win.rs b/crates/punktfunk-tray/src/win.rs index 24e0bfb4e..04f2f9853 100644 --- a/crates/punktfunk-tray/src/win.rs +++ b/crates/punktfunk-tray/src/win.rs @@ -477,16 +477,19 @@ fn show_menu(hwnd: HWND) { } if running { add(IDM_STOP, "Stop host", false, Some(win_theme::GLYPH_SHIELD)); + // "Restart Punktfunk", not "Restart host": this restarts the SERVICE, and the + // clients' host-power menus use "Restart host" for the MACHINE + // (design/host-actions.md §7) — one phrase must not mean two verbs. add( IDM_RESTART, - "Restart host", + "Restart Punktfunk", false, Some(win_theme::GLYPH_SHIELD), ); } else if matches!(status, TrayStatus::Error(_)) { add( IDM_RESTART, - "Restart host", + "Restart Punktfunk", false, Some(win_theme::GLYPH_SHIELD), ); diff --git a/docs-site/content/docs/access-levels.md b/docs-site/content/docs/access-levels.md index 430ad1ce2..9d2db31b2 100644 --- a/docs-site/content/docs/access-levels.md +++ b/docs-site/content/docs/access-levels.md @@ -30,7 +30,7 @@ The preset label is derived from the underlying toggles, so a hand-tuned combina ## The advanced toggles -Each preset is a bundle of six independent grants, under **Advanced** in the edit sheet: +Each preset is a bundle of independent grants, under **Advanced** in the edit sheet: | Toggle | Covers | |---|---| @@ -40,6 +40,7 @@ Each preset is a bundle of six independent grants, under **Advanced** in the edi | **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. | | **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. | | **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than dropped onto the bare desktop. The library stays *visible* — this governs launching, not browsing. | +| **Host power** | Sleeping, restarting or shutting down the host machine from the client (see [Host power](/docs/host-power)). Included in Full control on purpose: a device with Keyboard and Pointer can already reach the desktop's own power menu, so withholding only the polite path would be a lock painted on an open door. The bit's real job is keeping power away from *limited* devices — the controller-only guest and the view-only spectator cannot touch it. | **Controller only deliberately does not include Launch**: in co-play the owner drives what runs. Want a guest picking games? Turn on that one Advanced toggle. diff --git a/docs-site/content/docs/host-power.md b/docs-site/content/docs/host-power.md new file mode 100644 index 000000000..097975480 --- /dev/null +++ b/docs-site/content/docs/host-power.md @@ -0,0 +1,42 @@ +--- +title: Host power +description: Sleep, restart or shut down the host from the web console or a paired client — who may do it, what happens to running streams, and why an action can be refused. +--- + +[Wake-on-LAN](/docs/wake-on-lan) lets every client wake a sleeping host. Host power closes that +loop: **Sleep host**, **Restart host** and **Shut down host**, from the web console's Host page +(password-confirmed) or — with the right access — from a paired client. Finish playing on the TV, +sleep the host from the couch, wake it again tomorrow. + +## Who may do it + +- The **web console** always can — it is the operator's own surface, behind the console login + plus a per-action password confirmation. +- A **paired device** needs the **Host power** grant ([access levels](/docs/access-levels)). + Full-control devices have it — a device with keyboard access could already reach the desktop's + power menu, so Full control saying otherwise would be a fake distinction. Controller-only and + view-only devices do not, and cannot get it without the operator editing their access. + +## What happens + +On accept the host first ends every streaming session cleanly — clients show *"the host is going +to sleep or shutting down"* rather than a connection error — waits a moment so the reply reaches +the invoker, then asks the operating system to act. The host tile flips to asleep/offline, and for +sleep the **Wake host** action brings it back. + +## Why an action can be refused + +The host says no, with the reason, instead of pretending: + +- **Another device is streaming** — a granted guest cannot pull the host out from under someone + else's live session. Your own session never blocks you. The console is never blocked; it warns + instead. +- **The platform said no** — a Linux host honors other programs' suspend inhibitors and refuses + while a second local user is logged in; a machine that cannot suspend lists Sleep as + unavailable with the reason. Punktfunk deliberately does not force past these. +- On Linux the host user must be in group `punktfunk` — the same polkit rule the packages + install for unattended power operations. The console lists the action as unavailable with a + hint when the group is missing. + +Moonlight/GameStream clients have no vocabulary for this — host power is a native-protocol (and +console) feature. diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index 650a49fe3..f8782f8bf 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -31,6 +31,7 @@ "client-settings", "profiles-and-links", "wake-on-lan", + "host-power", "access-levels", "automation", "running-as-a-service", diff --git a/docs-site/content/docs/wake-on-lan.md b/docs-site/content/docs/wake-on-lan.md index ebca7bf71..f95ae9730 100644 --- a/docs-site/content/docs/wake-on-lan.md +++ b/docs-site/content/docs/wake-on-lan.md @@ -248,7 +248,9 @@ to wake the machine — if the adapter is not in it, nothing on the network can round that everywhere except the Linux app. - **Magic packets are broadcasts.** They do not cross subnets, a VPN or a mesh network. Client and host have to share a LAN segment. -- **Punktfunk never puts a host to sleep, and never wakes one on a schedule.** A packet goes out - because a connect needs it, or because you asked for one. +- **Punktfunk never sleeps or wakes a host on its own.** A wake packet goes out because a connect + needs it, or because you asked for one — and putting the host *back* to sleep is likewise an + explicit, permission-gated action: see [Host power](/docs/host-power) for the other half of the + round trip. - **There is no host-side switch.** The host publishes its MAC address and warns when its card is not armed. Whether to wake, when, and how long to wait is decided on the client. diff --git a/docs-site/public/openapi.json b/docs-site/public/openapi.json index 924b89db1..5c70fdb97 100644 --- a/docs-site/public/openapi.json +++ b/docs-site/public/openapi.json @@ -13,6 +13,114 @@ "version": "0.32.0" }, "paths": { + "/api/v1/actions": { + "get": { + "tags": [ + "actions" + ], + "summary": "List host actions", + "description": "The actions this host offers, as seen by the caller: platform availability (with the honest\nreason when something can't run) and whether THIS caller is permitted to invoke each one.\nAdmin lane: everything permitted. Paired-cert lane: permission follows the device's live\naccess mask (the Host power grant). Clients render rows generically — unknown ids still\nwork with the server-supplied title.", + "operationId": "listActions", + "responses": { + "200": { + "description": "The actions, per-caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionList" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/actions/{id}": { + "post": { + "tags": [ + "actions" + ], + "summary": "Invoke a host action", + "description": "Runs one action by id — empty body, no parameters: the id selects a fixed host-side\nbehavior, and nothing in the request reaches the privileged path. On `202` the host first\nends every streaming session cleanly (clients see a typed \"the host is going to sleep /\nshutting down\" close), waits ~1 s so this response flushes, then acts.\n\nPaired-cert callers need the **Host power** grant, and are refused (`409`) while another\ndevice's session is live — a granted guest cannot yank the host out from under the owner\nmid-stream. The admin console is never blocked (it warns instead). One action runs at a\ntime host-wide.", + "operationId": "invokeAction", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Action id (`power.sleep`, `power.reboot`, `power.shutdown`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted — sessions are being ended and the action follows in about a second" + }, + "401": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "This caller's access does not include this action (no Host power grant)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Unknown action id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Refused: an action is already in flight, another device's session is live (cert lane), or the platform said no (a foreign sleep inhibitor, a second local user, …)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "501": { + "description": "This host platform has no executor for it (macOS host)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/client-logs": { "get": { "tags": [ @@ -4331,6 +4439,67 @@ }, "components": { "schemas": { + "ActionInfo": { + "type": "object", + "description": "One action as the caller sees it (`GET /actions`).", + "required": [ + "id", + "title", + "group", + "danger", + "available", + "permitted" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)." + }, + "danger": { + "type": "boolean", + "description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)." + }, + "group": { + "type": "string", + "description": "Action group (`power` for the built-ins)." + }, + "id": { + "type": "string", + "description": "Stable action id (`power.sleep`, …) — the invoke path parameter.", + "example": "power.sleep" + }, + "permitted": { + "type": "boolean", + "description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)." + }, + "title": { + "type": "string", + "description": "Display title. Clients localize known ids and fall back to this for unknown ones." + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ], + "description": "Why it is unavailable, when it is." + } + } + }, + "ActionList": { + "type": "object", + "description": "`GET /actions` response.", + "required": [ + "actions" + ], + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionInfo" + } + } + } + }, "ActiveGame": { "type": "object", "description": "One launched game, for the console's running-game card.", @@ -6141,6 +6310,42 @@ } } }, + { + "type": "object", + "description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\").", + "required": [ + "id", + "outcome", + "kind" + ], + "properties": { + "device": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DeviceRef", + "description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)." + } + ] + }, + "id": { + "type": "string", + "description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)." + }, + "kind": { + "type": "string", + "enum": [ + "action.invoked" + ] + }, + "outcome": { + "type": "string", + "description": "`accepted`, or `failed: `." + } + } + }, { "type": "object", "required": [ @@ -9142,6 +9347,10 @@ { "name": "update", "description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)" + }, + { + "name": "actions", + "description": "Host actions: discover what this host offers (per-caller availability + permission) and invoke one by id — v1: sleep, restart, shut down the machine, gated per device by the Host power grant" } ] } diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index b2cf49967..0ebd72b1a 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -823,11 +823,25 @@ #define PUNKTFUNK_GRANT_LAUNCH (1 << 5) #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Host power: invoking the `power.*` host actions (sleep/reboot/shutdown) over the mgmt cert +// lane (`design/host-actions.md` §4). Route-gated like `CLIPBOARD`/`MIC`/`LAUNCH` — no +// datagram ever carries it, so [`classify`] is untouched. Machine power ONLY: future +// plugin/custom actions get their own class, never this bit. +#define PUNKTFUNK_GRANT_POWER (1 << 6) +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Every defined grant. Also the value an *absent* mask means — a record from before grants // existed (or an old host's Welcome that omits the field) is full control, so existing // pairings keep today's behavior. -#define PUNKTFUNK_GRANT_ALL (((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH) +#define PUNKTFUNK_GRANT_ALL ((((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH) | PUNKTFUNK_GRANT_POWER) +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`GRANT_ALL`] as it was before [`GRANT_POWER`] existed (hosts ≤ 0.32.x) — the mask an +// explicitly saved "Full control" wrote back then. See [`normalize_legacy_full`]. +#define PUNKTFUNK_GRANT_ALL_PRE_POWER (((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH) #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1945,6 +1959,11 @@ // bare desktop they didn't ask for. Connecting *without* a launch request still works. #define PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE 106 +// A host power action (`power.sleep`/`reboot`/`shutdown`, `design/host-actions.md`) is ending +// every session: the host is going to sleep or shutting down, deliberately — not a crash, not +// the network. Old clients render the generic close; acceptable degrade. +#define PUNKTFUNK_HOST_POWER_CLOSE_CODE 107 + // Minimum supported multiplier (renders under native, upscaled on present). #define PUNKTFUNK_MIN_SCALE 0.5 @@ -1981,6 +2000,7 @@ enum PunktfunkStatus PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29, PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED = -30, PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED = -31, + PUNKTFUNK_STATUS_REJECTED_HOST_POWER = -32, PUNKTFUNK_STATUS_PANIC = -99, }; #ifndef __cplusplus diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 2e4789809..d987d14fb 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -9,6 +9,8 @@ import * as HttpClientError from "effect/unstable/http/HttpClientError" import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" // non-recursive definitions +export type ActionInfo = { readonly "available": boolean, readonly "danger": boolean, readonly "group": string, readonly "id": string, readonly "permitted": boolean, readonly "title": string, readonly "unavailable_reason"?: string | null } +export const ActionInfo = Schema.Struct({ "available": Schema.Boolean.annotate({ "description": "Whether this host can run it right now (platform probe — a VM that can't S3 lists\nsleep as unavailable rather than offering a dead switch)." }), "danger": Schema.Boolean.annotate({ "description": "Whether a client UI should double-confirm (the action loses state — reboot/shutdown)." }), "group": Schema.String.annotate({ "description": "Action group (`power` for the built-ins)." }), "id": Schema.String.annotate({ "description": "Stable action id (`power.sleep`, …) — the invoke path parameter." }), "permitted": Schema.Boolean.annotate({ "description": "Whether THIS caller may invoke it (admin lane: always; cert lane: the `GRANT_POWER`\nbit of the device's live access mask)." }), "title": Schema.String.annotate({ "description": "Display title. Clients localize known ids and fall back to this for unknown ones." }), "unavailable_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why it is unavailable, when it is." })) }).annotate({ "description": "One action as the caller sees it (`GET /actions`)." }) export type ActiveGame = { readonly "app_id"?: string | null, readonly "client": string, readonly "grace_remaining_s"?: never, readonly "plane": "native" | "gamestream", readonly "session_id"?: never, readonly "state": string, readonly "store"?: string | null, readonly "title": string } export const ActiveGame = Schema.Struct({ "app_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command." })), "client": Schema.String.annotate({ "description": "Client-supplied device name of the session that launched it; may be empty." }), "grace_remaining_s": Schema.optionalKey(Schema.Never), "plane": Schema.Literals(["native", "gamestream"]).annotate({ "description": "`native` or `gamestream`." }), "session_id": Schema.optionalKey(Schema.Never), "state": Schema.String.annotate({ "description": "`launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title\nexposes nothing the host can recognize its process by, so its exit will never be noticed),\nor `grace` (its session is gone and it will be ended when the reconnect window closes)." }), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." })), "title": Schema.String.annotate({ "description": "Display title." }) }).annotate({ "description": "One launched game, for the console's running-game card." }) export type ApiCodec = "h264" | "hevc" | "av1" | "pyrowave" @@ -163,6 +165,8 @@ export type UpdateNativeAccess = { readonly "clear_expiry"?: boolean | null, rea export const UpdateNativeAccess = Schema.Struct({ "clear_expiry": Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null]).annotate({ "description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)." })), "expires_in_secs": Schema.optionalKey(Schema.Never), "grants": Schema.optionalKey(Schema.Never) }).annotate({ "description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes." }) export type UpdateStatus = { readonly "apply": string, readonly "available": boolean, readonly "channel": string, readonly "channel_hint": string, readonly "check_disabled": boolean, readonly "current_version": string, readonly "install_kind": string, readonly "job"?: null | { readonly "received_bytes": number, readonly "stage": string, readonly "started_unix": number, readonly "target_version": string, readonly "total_bytes"?: never }, readonly "last_checked_unix"?: never, readonly "last_error"?: string | null, readonly "last_result"?: null | { readonly "error"?: string | null, readonly "finished_unix": number, readonly "from": string, readonly "log_path"?: string | null, readonly "ok": boolean, readonly "stage"?: string | null, readonly "staged"?: boolean, readonly "to": string }, readonly "manifest"?: null | { readonly "notes_url": string, readonly "published_at": string, readonly "serial": number, readonly "stale": boolean, readonly "version": string }, readonly "not_published": boolean, readonly "opt_in_hint"?: string | null } export const UpdateStatus = Schema.Struct({ "apply": Schema.String.annotate({ "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." }), "available": Schema.Boolean.annotate({ "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." }), "channel": Schema.String.annotate({ "description": "Release channel this install follows: `stable` | `canary`." }), "channel_hint": Schema.String.annotate({ "description": "The copy-pastable update command for this install kind." }), "check_disabled": Schema.Boolean.annotate({ "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." }), "current_version": Schema.String.annotate({ "description": "The running host version." }), "install_kind": Schema.String.annotate({ "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." }), "job": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "received_bytes": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stage": Schema.String.annotate({ "description": "`downloading` | `verifying` | `applying` | `restarting`." }), "started_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target_version": Schema.String.annotate({ "description": "The version being installed." }), "total_bytes": Schema.optionalKey(Schema.Never) }).annotate({ "description": "The apply in flight, if any." })], { mode: "oneOf" })), "last_checked_unix": Schema.optionalKey(Schema.Never), "last_error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the last check failed, verbatim, if it did." })), "last_result": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "from": Schema.String, "log_path": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The installer's own log file on this host, for diagnosis." })), "ok": Schema.Boolean, "stage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stage that failed; absent on success." })), "staged": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Applied but activates on the next reboot (rpm-ostree)." })), "to": Schema.String }).annotate({ "description": "Outcome of the most recent apply attempt." })], { mode: "oneOf" })), "manifest": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "notes_url": Schema.String.annotate({ "description": "Release-notes link (pinned to our forge by the manifest validator)." }), "published_at": Schema.String.annotate({ "description": "RFC-3339 publish time (display only)." }), "serial": Schema.Number.annotate({ "description": "Publish serial (unix seconds) — monotonic per channel.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stale": Schema.Boolean.annotate({ "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." }), "version": Schema.String.annotate({ "description": "The released version this manifest announces." }) }).annotate({ "description": "The last verified manifest, if any check has succeeded." })], { mode: "oneOf" })), "not_published": Schema.Boolean.annotate({ "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." }), "opt_in_hint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." })) }).annotate({ "description": "The full update-check state for this host." }) +export type ActionList = { readonly "actions": ReadonlyArray } +export const ActionList = Schema.Struct({ "actions": Schema.Array(ActionInfo) }).annotate({ "description": "`GET /actions` response." }) export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio"?: null | { readonly "last_resort": boolean, readonly "loopback"?: string | null, readonly "mic"?: string | null, readonly "mic_withheld": boolean, readonly "narrowing"?: string | null, readonly "readiness": string }, readonly "audio_streaming": boolean, readonly "games": ReadonlyArray, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "last_resort": Schema.Boolean.annotate({ "description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes." }), "loopback": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable." })), "mic": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable." })), "mic_withheld": Schema.Boolean.annotate({ "description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable." }), "narrowing": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does." })), "readiness": Schema.String.annotate({ "description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all." }) }).annotate({ "description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime." })], { mode: "oneOf" })), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) export type DisplayStateResponse = { readonly "displays": ReadonlyArray } @@ -211,8 +215,8 @@ export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recov export const StatsSample = Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "description": "Configured target bitrate.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fec_recovered": Schema.Number.annotate({ "description": "FEC shards recovered this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fps": Schema.Number.annotate({ "description": "Genuine NEW frames/s from the source.", "format": "float" }).check(Schema.isFinite()), "frames_dropped": Schema.Number.annotate({ "description": "Frames dropped this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mbps": Schema.Number.annotate({ "description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.", "format": "float" }).check(Schema.isFinite()), "packets_dropped": Schema.Number.annotate({ "description": "Packets dropped this window (receiver-side / reassembler, where known).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "repeat_fps": Schema.Number.annotate({ "description": "Re-encoded holds/s (source-starvation indicator).", "format": "float" }).check(Schema.isFinite()), "send_dropped": Schema.Number.annotate({ "description": "Host send-buffer overflow / EAGAIN this window (delta).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "session_id": Schema.Number.annotate({ "description": "Disambiguates concurrent sessions (usually constant).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stages": Schema.Array(StageTiming).annotate({ "description": "Ordered pipeline stages for this path." }), "t_ms": Schema.Number.annotate({ "description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream)." }) export type Job = { readonly "error"?: string | null, readonly "finished_at"?: never, readonly "id": string, readonly "kind": string, readonly "log": ReadonlyArray, readonly "phase": string, readonly "started_at": number, readonly "state": State, readonly "target": string } export const Job = Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_at": Schema.optionalKey(Schema.Never), "id": Schema.String, "kind": Schema.String.annotate({ "description": "`install` or `uninstall`." }), "log": Schema.Array(Schema.String).annotate({ "description": "Tail of the runner's combined stdout/stderr." }), "phase": Schema.String.annotate({ "description": "Coarse step name, for a progress line the operator can read." }), "started_at": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "state": State, "target": Schema.String.annotate({ "description": "What the operator asked for — a package name, or the raw spec they typed." }) }).annotate({ "description": "A job as the console sees it. Field names are snake_case like the rest of the management API\n(the *file* formats — index, sources, manifest — follow npm's camelCase instead)." }) -export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.granted", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "access.expired", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "channel": string, readonly "install_kind": string, readonly "kind": "update.available", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "from": string, readonly "kind": "update.applied", readonly "to": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } -export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.granted"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*)." }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\"." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("access.expired"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way)." }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "channel": Schema.String.annotate({ "description": "The channel it was announced on (`stable` | `canary`)." }), "install_kind": Schema.String.annotate({ "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." }), "kind": Schema.Literal("update.available"), "version": Schema.String.annotate({ "description": "The newer release's version string." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh)." }), Schema.Struct({ "from": Schema.String, "kind": Schema.Literal("update.applied"), "to": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply." }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) +export type HostEvent = { readonly "client": ClientRef, readonly "kind": "client.connected", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "client": ClientRef, readonly "kind": "client.disconnected", readonly "reason": DisconnectReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.started", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "session.ended", readonly "session": SessionRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.started", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "stream.stopped", readonly "stream": StreamRef, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.running", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "game": GameRefPayload, readonly "kind": "game.exited", readonly "reason": GameEndReason, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.pending", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.completed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "pairing.denied", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.granted", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "expires_unix"?: never, readonly "grants": number, readonly "kind": "access.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device": DeviceRef, readonly "kind": "access.expired", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "backend": string, readonly "kind": "display.created", readonly "mode": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "count": number, readonly "kind": "display.released", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "library.changed", readonly "source": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "channel": string, readonly "install_kind": string, readonly "kind": "update.available", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "from": string, readonly "kind": "update.applied", readonly "to": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "id": string, readonly "kind": "plugins.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "store.changed", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "device"?: null | { readonly "fingerprint": string, readonly "name": string, readonly "plane": Plane }, readonly "id": string, readonly "kind": "action.invoked", readonly "outcome": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "gamestream": boolean, readonly "kind": "host.started", readonly "version": string, readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } | { readonly "kind": "host.stopping", readonly "schema": number, readonly "seq": number, readonly "ts_ms": number } +export const HostEvent = Schema.Union([Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.connected"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "client": ClientRef, "kind": Schema.Literal("client.disconnected"), "reason": DisconnectReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.started"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("session.ended"), "session": SessionRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.started"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("stream.stopped"), "stream": StreamRef, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.running"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher)." }), Schema.Struct({ "game": GameRefPayload, "kind": Schema.Literal("game.exited"), "reason": GameEndReason, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.pending"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.completed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("pairing.denied"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.granted"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*)." }), Schema.Struct({ "device": DeviceRef, "expires_unix": Schema.optionalKey(Schema.Never), "grants": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("access.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\"." }), Schema.Struct({ "device": DeviceRef, "kind": Schema.Literal("access.expired"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way)." }), Schema.Struct({ "backend": Schema.String.annotate({ "description": "The virtual-display backend that minted it (`VirtualDisplay::name`)." }), "kind": Schema.Literal("display.created"), "mode": Schema.String.annotate({ "description": "`WxH@Hz`." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "count": Schema.Number.annotate({ "description": "How many kept displays this release retired.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "kind": Schema.Literal("display.released"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("library.changed"), "source": Schema.String.annotate({ "description": "What mutated the library: `\"manual\"` today; a provider id once the provider\nAPI (RFC §8) lands." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "channel": Schema.String.annotate({ "description": "The channel it was announced on (`stable` | `canary`)." }), "install_kind": Schema.String.annotate({ "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." }), "kind": Schema.Literal("update.available"), "version": Schema.String.annotate({ "description": "The newer release's version string." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh)." }), Schema.Struct({ "from": Schema.String, "kind": Schema.Literal("update.applied"), "to": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply." }), Schema.Struct({ "id": Schema.String.annotate({ "description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set." }), "kind": Schema.Literal("plugins.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("store.changed"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The set of installed plugins, or what the store knows about them, changed — an install or\nuninstall finished, or a catalog refresh brought in new rows. A consumer re-reads\n`GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer\nis a join over several sources of truth, so \"go look again\" is the only honest signal." }), Schema.Struct({ "device": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fingerprint": Schema.String.annotate({ "description": "Hex certificate fingerprint." }), "name": Schema.String.annotate({ "description": "Sanitized device name (the pairing store's copy)." }), "plane": Plane }).annotate({ "description": "The invoking paired device, when the cert lane invoked it; absent for the\noperator's console (admin lane)." })], { mode: "oneOf" })), "id": Schema.String.annotate({ "description": "The invoked action id (`power.sleep`, `power.reboot`, `power.shutdown`)." }), "kind": Schema.Literal("action.invoked"), "outcome": Schema.String.annotate({ "description": "`accepted`, or `failed: `." }), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A host action was invoked (`design/host-actions.md` §3.3) — v1: the `power.*` verbs.\nEmitted on ACCEPT (`outcome: \"accepted\"`), and again if the executor later fails\n(`outcome: \"failed: …\"`) — a succeeded power action ends this process, so \"accepted with\nno failure after it\" is the success signal a hook can act on (\"the host is going down\")." }), Schema.Struct({ "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight compat plane is enabled." }), "kind": Schema.Literal("host.started"), "version": Schema.String, "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }), Schema.Struct({ "kind": Schema.Literal("host.stopping"), "schema": Schema.Number.annotate({ "description": "Wire-shape version ([`SCHEMA_VERSION`]).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) })], { mode: "oneOf" }).annotate({ "description": "The event kind + payload, flattened: `\"kind\": \"stream.started\", …payload…`." }) export type CustomPreset = { readonly "fields": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "game_session"?: "auto" | "dedicated", readonly "id": string, readonly "name": string } export const CustomPreset = Schema.Struct({ "fields": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." }), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "name": Schema.String.annotate({ "description": "User-facing name shown on the preset card; editable." }) }).annotate({ "description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change)." }) export type DisplayPolicy = { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "edid_lock"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } @@ -230,6 +234,20 @@ export const CustomPresetInput = Schema.Struct({ "fields": EffectivePolicy, "gam export type DisplaySettingsState = { readonly "configured": boolean, readonly "custom_presets": ReadonlyArray, readonly "effective": { readonly "identity": Identity, readonly "keep_alive": KeepAlive, readonly "layout": Layout, readonly "max_displays": number, readonly "mode_conflict": ModeConflict, readonly "topology": Topology }, readonly "enforced": ReadonlyArray, readonly "presets": ReadonlyArray, readonly "settings": { readonly "capture_monitor"?: string | null, readonly "ddc_power_off"?: boolean, readonly "edid_lock"?: boolean, readonly "game_session"?: "auto" | "dedicated", readonly "identity"?: Identity, readonly "keep_alive"?: KeepAlive, readonly "layout"?: Layout, readonly "max_displays"?: number, readonly "mode_conflict"?: ModeConflict, readonly "pnp_disable_monitors"?: boolean, readonly "preset"?: Preset, readonly "topology"?: Topology, readonly "version"?: number } } export const DisplaySettingsState = Schema.Struct({ "configured": Schema.Boolean.annotate({ "description": "True once a `display-settings.json` exists (the console has configured this host)." }), "custom_presets": Schema.Array(CustomPreset).annotate({ "description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields." }), "effective": Schema.Struct({ "identity": Identity, "keep_alive": KeepAlive, "layout": Layout, "max_displays": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "mode_conflict": ModeConflict, "topology": Topology }).annotate({ "description": "The effective (preset-expanded) policy currently in force." }), "enforced": Schema.Array(Schema.String).annotate({ "description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)." }), "presets": Schema.Array(PresetInfo).annotate({ "description": "Every named preset and what it expands to (for the picker's preview)." }), "settings": Schema.Struct({ "capture_monitor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it." })), "ddc_power_off": Schema.optionalKey(Schema.Boolean.annotate({ "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." })), "edid_lock": Schema.optionalKey(Schema.Boolean.annotate({ "description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off." })), "game_session": Schema.optionalKey(Schema.Literals(["auto", "dedicated"]).annotate({ "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." })), "identity": Schema.optionalKey(Identity), "keep_alive": Schema.optionalKey(KeepAlive), "layout": Schema.optionalKey(Layout), "max_displays": Schema.optionalKey(Schema.Number.annotate({ "description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "mode_conflict": Schema.optionalKey(ModeConflict), "pnp_disable_monitors": Schema.optionalKey(Schema.Boolean.annotate({ "description": "EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's\nduration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan /\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off." })), "preset": Schema.optionalKey(Preset), "topology": Schema.optionalKey(Topology), "version": Schema.optionalKey(Schema.Number.annotate({ "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) }).annotate({ "description": "The stored policy (preset + custom fields), or the built-in default when unconfigured." }) }).annotate({ "description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on)." }) // schemas +export type ListActions200 = ActionList +export const ListActions200 = ActionList +export type ListActions401 = ApiError +export const ListActions401 = ApiError +export type InvokeAction401 = ApiError +export const InvokeAction401 = ApiError +export type InvokeAction403 = ApiError +export const InvokeAction403 = ApiError +export type InvokeAction404 = ApiError +export const InvokeAction404 = ApiError +export type InvokeAction409 = ApiError +export const InvokeAction409 = ApiError +export type InvokeAction501 = ApiError +export const InvokeAction501 = ApiError export type ClientLogsList200 = ReadonlyArray export const ClientLogsList200 = Schema.Array(ClientLogMeta) export type ClientLogsList401 = ApiError @@ -891,6 +909,24 @@ export const make = ( ) return { httpClient, + "listActions": (options) => HttpClientRequest.get(`/api/v1/actions`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ListActions200), + "401": decodeError("ListActions401", ListActions401), + orElse: unexpectedStatus + })) + ), + "invokeAction": (id, options) => HttpClientRequest.post(`/api/v1/actions/${id}`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "401": decodeError("InvokeAction401", InvokeAction401), + "403": decodeError("InvokeAction403", InvokeAction403), + "404": decodeError("InvokeAction404", InvokeAction404), + "409": decodeError("InvokeAction409", InvokeAction409), + "501": decodeError("InvokeAction501", InvokeAction501), + "202": () => Effect.void, + orElse: unexpectedStatus + })) + ), "clientLogsList": (options) => HttpClientRequest.get(`/api/v1/client-logs`).pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ClientLogsList200), @@ -1634,6 +1670,26 @@ export const make = ( export interface Punktfunk { readonly httpClient: HttpClient.HttpClient /** +* The actions this host offers, as seen by the caller: platform availability (with the honest +* reason when something can't run) and whether THIS caller is permitted to invoke each one. +* Admin lane: everything permitted. Paired-cert lane: permission follows the device's live +* access mask (the Host power grant). Clients render rows generically — unknown ids still +* work with the server-supplied title. +*/ +readonly "listActions": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListActions401", typeof ListActions401.Type>> + /** +* Runs one action by id — empty body, no parameters: the id selects a fixed host-side +* behavior, and nothing in the request reaches the privileged path. On `202` the host first +* ends every streaming session cleanly (clients see a typed "the host is going to sleep / +* shutting down" close), waits ~1 s so this response flushes, then acts. +* +* Paired-cert callers need the **Host power** grant, and are refused (`409`) while another +* device's session is live — a granted guest cannot yank the host out from under the owner +* mid-stream. The admin console is never blocked (it warns instead). One action runs at a +* time host-wide. +*/ +readonly "invokeAction": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"InvokeAction401", typeof InvokeAction401.Type> | PunktfunkError<"InvokeAction403", typeof InvokeAction403.Type> | PunktfunkError<"InvokeAction404", typeof InvokeAction404.Type> | PunktfunkError<"InvokeAction409", typeof InvokeAction409.Type> | PunktfunkError<"InvokeAction501", typeof InvokeAction501.Type>> + /** * Every stored bundle's metadata, newest first. */ readonly "clientLogsList": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ClientLogsList401", typeof ClientLogsList401.Type>> diff --git a/web/messages/de.json b/web/messages/de.json index 90b22637c..78423a741 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -134,6 +134,14 @@ "host_codecs": "Codecs", "host_ports": "Ports", "host_uniqueid": "Eindeutige ID", + "host_power_title": "Host-Energie", + "host_power_sleep": "Host schlafen legen", + "host_power_reboot": "Host neu starten", + "host_power_shutdown": "Host herunterfahren", + "host_power_confirm_title": "{action}?", + "host_power_confirm_body": "Alle aktiven Streams werden zuerst beendet. Mit dem Konsolen-Passwort bestätigen.", + "host_power_sent": "{action} — gesendet. Der Host fährt herunter.", + "host_power_working": "Wird gesendet…", "host_compositors": "Compositoren", "host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.", "compositor_available": "Verfügbar", @@ -303,6 +311,8 @@ "access_grant_clipboard": "Zwischenablage", "access_grant_mic": "Mikrofon", "access_grant_launch": "Spiele starten", + "access_grant_power": "Host-Energie", + "access_grant_power_caption": "Host schlafen legen, neu starten oder herunterfahren. Mit Tastaturzugriff geht das ohnehin über den Desktop.", "access_expires_label": "Zugriff läuft ab", "access_expires_keep": "Aktuellen Ablauf beibehalten", "access_expires_forever": "Nie", diff --git a/web/messages/en.json b/web/messages/en.json index 3394cc3bb..b12995d52 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -134,6 +134,14 @@ "host_codecs": "Codecs", "host_ports": "Ports", "host_uniqueid": "Unique ID", + "host_power_title": "Host power", + "host_power_sleep": "Sleep host", + "host_power_reboot": "Restart host", + "host_power_shutdown": "Shut down host", + "host_power_confirm_title": "{action}?", + "host_power_confirm_body": "Every active stream ends first. Confirm with your console password.", + "host_power_sent": "{action} — sent. The host is on its way down.", + "host_power_working": "Sending…", "host_compositors": "Compositors", "host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.", "compositor_available": "Available", @@ -303,6 +311,8 @@ "access_grant_clipboard": "Clipboard", "access_grant_mic": "Microphone", "access_grant_launch": "Launch games", + "access_grant_power": "Host power", + "access_grant_power_caption": "Sleep, restart or shut down the host. Keyboard access can already reach this via the desktop.", "access_expires_label": "Access expires", "access_expires_keep": "Keep current expiry", "access_expires_forever": "Never", diff --git a/web/server/routes/api/v1/actions/[id].post.ts b/web/server/routes/api/v1/actions/[id].post.ts new file mode 100644 index 000000000..7e3f21a64 --- /dev/null +++ b/web/server/routes/api/v1/actions/[id].post.ts @@ -0,0 +1,22 @@ +// POST /api/v1/actions/{id} — invoking a host action (sleep/reboot/shutdown, +// design/host-actions.md) is password-gated like update/apply: a 7-day session cookie alone +// must not be able to power the machine off. The password is verified here (only the BFF knows +// it), stripped, and never forwarded — the upstream request body is EMPTY by design (the id is +// the whole request; no field reaches the host's privileged path). +// +// This specific file wins over the `[...]` catch-all (h3 route specificity), which is what +// keeps the catch-all from proxying the invoke UNgated. +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { confirmPassword } from "../../../../util/confirm"; +import { forwardJson } from "../../../../util/forward"; + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id") ?? ""; + const body = await readBody<{ password?: string }>(event); + confirmPassword(event, body?.password); + return forwardJson( + event, + `/api/v1/actions/${encodeURIComponent(id)}`, + "POST", + ); +}); diff --git a/web/server/util/confirm.ts b/web/server/util/confirm.ts index 22d869237..87da8879b 100644 --- a/web/server/util/confirm.ts +++ b/web/server/util/confirm.ts @@ -11,6 +11,10 @@ // - PUT /api/v1/hooks — a hook is a shell command the host runs on its events // - the library writes that carry `prep`/`launch.kind == "command"` — same primitive, gated // conditionally in util/libraryConfirm.ts +// - POST /api/v1/actions/{id} — the host power actions (sleep/reboot/shutdown, +// design/host-actions.md §7): ending the machine from +// a 7-day cookie alone is exactly what this gate exists +// to prevent // - the PAIRING routes — arming a window, approving a knock, submitting a GameStream PIN. A // paired device injects keyboard and mouse on the host desktop, so admitting one IS code // execution, and it was the shortest path past this gate (security-review 2026-08-25). diff --git a/web/src/sections/Host/PowerCard.tsx b/web/src/sections/Host/PowerCard.tsx new file mode 100644 index 000000000..889da75df --- /dev/null +++ b/web/src/sections/Host/PowerCard.tsx @@ -0,0 +1,182 @@ +import { type FC, useState } from "react"; +import { useListActions } from "@/api/gen/actions/actions"; +import type { ActionInfo } from "@/api/gen/model"; +import { QueryState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { m } from "@/paraglide/messages"; + +/** Localized titles for the KNOWN action ids; unknown ids fall back to the server's title — + * the contract that lets future host actions appear with no console release. */ +const actionTitle = (a: ActionInfo): string => { + switch (a.id) { + case "power.sleep": + return m.host_power_sleep(); + case "power.reboot": + return m.host_power_reboot(); + case "power.shutdown": + return m.host_power_shutdown(); + default: + return a.title; + } +}; + +/** + * Host power (design/host-actions.md §7, the admin lane's free win — also the "no restart + * route" gap the update design named): the discovered host actions as password-confirmed + * buttons. Unavailable actions render disabled with the host's honest reason instead of + * being hidden. + */ +export const PowerSection: FC = () => { + const actions = useListActions(); + const [confirming, setConfirming] = useState(null); + const [sent, setSent] = useState(null); + const list = actions.data?.actions ?? []; + + return ( + + + {m.host_power_title()} + + + +
+ {list.map((a) => ( + + ))} +
+ {list + .filter((a) => !a.available && a.unavailable_reason) + .map((a) => ( +

+ {actionTitle(a)}: {a.unavailable_reason} +

+ ))} + {sent &&

{sent}

} +
+ {confirming && ( + setConfirming(null)} + onAccepted={(a) => { + setConfirming(null); + setSent(m.host_power_sent({ action: actionTitle(a) })); + }} + /> + )} +
+
+ ); +}; + +/** The password-confirm dialog — the update-apply recipe: plain fetch (a 401 here is a wrong + * password, not an expired session), password verified and stripped in the BFF. */ +const ConfirmDialog: FC<{ + action: ActionInfo; + onClose: () => void; + onAccepted: (action: ActionInfo) => void; +}> = ({ action, onClose, onAccepted }) => { + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async () => { + setBusy(true); + setError(null); + try { + const res = await fetch( + `/api/v1/actions/${encodeURIComponent(action.id)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ password }), + }, + ); + if (res.status === 202) { + onAccepted(action); + return; + } + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + if (res.status === 401) setError(m.update_apply_wrong_password()); + else if (res.status === 429) setError(m.update_apply_throttled()); + else setError(body?.error ?? `HTTP ${res.status}`); + } catch { + setError(m.common_error()); + } finally { + setBusy(false); + } + }; + + return ( + !o && onClose()}> + + + + {m.host_power_confirm_title({ action: actionTitle(action) })} + + {m.host_power_confirm_body()} + +
{ + e.preventDefault(); + void submit(); + }} + > +
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ {error &&

{error}

} + + + +
+
+
+ ); +}; diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx index ff4d85e02..59d5b6c8f 100644 --- a/web/src/sections/Host/index.tsx +++ b/web/src/sections/Host/index.tsx @@ -3,6 +3,7 @@ import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host"; import { useLocale } from "@/lib/i18n"; import { ConflictsCard } from "./ConflictsCard"; import { GpuSection } from "./GpuCard"; +import { PowerSection } from "./PowerCard"; import { UpdateSection } from "./UpdateCard"; import { HostView } from "./view"; @@ -18,6 +19,7 @@ export const SectionHost: FC = () => { conflicts={} gpu={} update={} + power={} /> ); }; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index a239b72a4..20f67ec66 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -17,10 +17,12 @@ export const HostView: FC<{ gpu?: ReactNode; /** The update-check card (a self-contained container — see `UpdateCard.tsx`). */ update?: ReactNode; + /** The host-power actions card (a self-contained container — see `PowerCard.tsx`). */ + power?: ReactNode; /** Warning about other Moonlight-compatible servers on this machine — renders nothing when * there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */ conflicts?: ReactNode; -}> = ({ host, compositors, gpu, update, conflicts }) => { +}> = ({ host, compositors, gpu, update, power, conflicts }) => { const h = host.data; return (
@@ -99,6 +101,8 @@ export const HostView: FC<{ {update} + {power} + {gpu} diff --git a/web/src/sections/Pairing/access.tsx b/web/src/sections/Pairing/access.tsx index f5d8acd50..0e04762bb 100644 --- a/web/src/sections/Pairing/access.tsx +++ b/web/src/sections/Pairing/access.tsx @@ -27,7 +27,19 @@ export const GRANT_KEYBOARD = 0x04; export const GRANT_CLIPBOARD = 0x08; export const GRANT_MIC = 0x10; export const GRANT_LAUNCH = 0x20; -export const GRANT_ALL = 0x3f; +export const GRANT_POWER = 0x40; +export const GRANT_ALL = 0x7f; + +/** `GRANT_ALL` before the Power bit existed (hosts ≤ 0.32.x). */ +const GRANT_ALL_PRE_POWER = 0x3f; + +/** + * The legacy-full read rule (host-actions §4.3): a stored mask that is EXACTLY the pre-power + * full mask was an explicit "Full control" from before Power existed — read it as today's + * `GRANT_ALL`, so the chip stays "Full" and the edit sheet's toggles agree with it. + */ +export const normalizeLegacyFull = (mask: number): number => + mask === GRANT_ALL_PRE_POWER ? GRANT_ALL : mask; /** The guest preset (D2): controller only, WITHOUT launch — the owner drives what runs. */ export const PRESET_CONTROLLER = GRANT_GAMEPAD; @@ -39,7 +51,7 @@ export type AccessLevel = "full" | "controller" | "view" | "custom"; /** Preset name for a mask — derived, never stored (design §3.2). */ export const levelOfMask = (mask: number): AccessLevel => { - switch (mask & GRANT_ALL) { + switch (normalizeLegacyFull(mask) & GRANT_ALL) { case GRANT_ALL: return "full"; case PRESET_CONTROLLER: @@ -104,8 +116,9 @@ export const draftFromStored = ( expiresUnix: number | null | undefined, grantedUnix: number | null | undefined, ): AccessDraft => { - // null grants = a pre-grants record = full control (the API contract). - const mask = grants ?? GRANT_ALL; + // null grants = a pre-grants record = full control (the API contract); an explicit + // pre-power full mask normalizes so the toggles agree with the "Full" chip. + const mask = normalizeLegacyFull(grants ?? GRANT_ALL); if (expiresUnix == null || grantedUnix == null || expiresUnix <= grantedUnix) return { grants: mask, expiry: "forever", customHours: 4 }; const secs = expiresUnix - grantedUnix; @@ -168,14 +181,24 @@ export const AccessChip: FC<{ ); }; -/** The six toggles behind Advanced, in bit order. Labels name what the bit covers. */ -const GRANT_TOGGLES: { bit: number; label: () => string }[] = [ +/** The toggles behind Advanced, in bit order. Labels name what the bit covers. */ +const GRANT_TOGGLES: { + bit: number; + label: () => string; + /** One line under the label, for a bit whose reach isn't obvious from its name alone. */ + caption?: () => string; +}[] = [ { bit: GRANT_GAMEPAD, label: () => m.access_grant_gamepad() }, { bit: GRANT_POINTER, label: () => m.access_grant_pointer() }, { bit: GRANT_KEYBOARD, label: () => m.access_grant_keyboard() }, { bit: GRANT_CLIPBOARD, label: () => m.access_grant_clipboard() }, { bit: GRANT_MIC, label: () => m.access_grant_mic() }, { bit: GRANT_LAUNCH, label: () => m.access_grant_launch() }, + { + bit: GRANT_POWER, + label: () => m.access_grant_power(), + caption: () => m.access_grant_power_caption(), + }, ]; /** @@ -243,7 +266,7 @@ export const AccessControls: FC<{ {advanced && (
- {GRANT_TOGGLES.map(({ bit, label }) => ( + {GRANT_TOGGLES.map(({ bit, label, caption }) => ( ))}
-- 2.54.0 From 62728317cf38300c86f32f9293fa6b4218e7cac8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 28 Aug 2026 23:26:02 +0200 Subject: [PATCH 2/3] The couch gets the other half of the round trip: host power on every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/host-actions.md P2. The host has offered sleep/restart/shutdown since the previous commit; this is where a person can reach them. Every client's host menu grows the rows right where Wake host appears when the machine is asleep — finish on the TV, sleep the host from the same menu that woke it. One shared core: pf_client_core::host_actions holds the discovery read, the id-only invoke, and a process-wide TTL cache the console, the GTK page and the Windows tile all read, so three shells cannot end up disagreeing about what a host offers. Every shell warms it on a refresh tick it already had. Discovery is PRE-fetched, never fetched when a menu opens: the console screen holds a cloned row, and rows that appear under a cursor already moving are a hazard when two of them shut a machine down. For the same reason the console's armed state became WHICH row is armed rather than a bare flag — a flag let an arming press on Forget fire Shut down host. Restart and shut down confirm; sleep is reversible from the same menu, so it goes on one press. An action the host says it cannot run right now stays listed, disabled, and says why. Surfaces: console-UI rows + ConsoleCmd::HostAction with session and Android dispatch; GTK card menu (plus a card-level Toast) with an AlertDialog confirm; Windows tile menu, its label built and matched through one function so a menu whose rows outlived their handlers cannot run a different verb; Android touch home and Skia console; Apple host card and gamepad options. Unknown ids render from the host's own title on all of them — a later host action needs no client release to appear. Typed close: the Swift hostPower case and the Kotlin host-power token. Without them, sleeping your own host from the couch reads as a crash on those two clients. --- .../io/unom/punktfunk/ConnectDialogs.kt | 30 ++ .../kotlin/io/unom/punktfunk/ConnectErrors.kt | 5 + .../kotlin/io/unom/punktfunk/ConnectGrid.kt | 15 + .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 88 ++++++ .../kotlin/io/unom/punktfunk/HostActions.kt | 121 ++++++++ .../io/unom/punktfunk/console/ConsoleJson.kt | 22 ++ .../io/unom/punktfunk/console/SkiaConsole.kt | 69 ++++- .../Home/GamepadHomeView.swift | 7 + .../Home/GamepadHostOptionsView.swift | 99 ++++++- .../PunktfunkClient/Home/HomeView.swift | 69 +++++ .../PunktfunkClient/Home/HostCards.swift | 18 ++ .../PunktfunkClient/Support/HostPower.swift | 98 +++++++ .../Connection/LibraryClient.swift | 106 +++++++ .../Connection/PunktfunkConnection.swift | 8 + clients/linux/src/app.rs | 92 ++++++ clients/linux/src/ui_hosts.rs | 100 ++++++- clients/session/src/console.rs | 67 +++++ clients/windows/src/app/hosts.rs | 98 +++++++ crates/pf-client-core/src/host_actions.rs | 272 ++++++++++++++++++ crates/pf-client-core/src/lib.rs | 6 + crates/pf-console-ui/src/lib.rs | 2 +- crates/pf-console-ui/src/model.rs | 49 ++++ .../pf-console-ui/src/screens/bind_profile.rs | 1 + .../pf-console-ui/src/screens/collections.rs | 1 + crates/pf-console-ui/src/screens/home.rs | 1 + crates/pf-console-ui/src/screens/library.rs | 1 + crates/pf-console-ui/src/screens/options.rs | 230 ++++++++++++++- crates/pf-console-ui/src/screens/pair.rs | 1 + crates/pf-console-ui/src/screens/pin_hosts.rs | 1 + crates/pf-console-ui/src/screens/settings.rs | 1 + crates/pf-console-ui/src/shell/tests.rs | 1 + docs-site/content/docs/host-power.md | 5 + 32 files changed, 1660 insertions(+), 24 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/HostActions.kt create mode 100644 clients/apple/Sources/PunktfunkClient/Support/HostPower.swift create mode 100644 crates/pf-client-core/src/host_actions.rs diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 0abbcd636..5f14d9ac3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -309,3 +309,33 @@ internal fun EditHostDialog( }, ) } + +/** + * "Restart host?" / "Shut down host?" — the confirmation a destructive host action takes before + * it runs (`design/host-actions.md` §7). Sleep is reversible from the same menu ("Wake host"), + * so it never reaches here; restart and shut down lose whatever is on that machine, so they do. + */ +@Composable +internal fun HostActionConfirmDialog( + hostName: String, + action: HostActions.Action, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("${action.label}?") }, + text = { + Text( + "This ends every stream from $hostName and anything running on it. " + + "You'll need to wake or start it again.", + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(action.label) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt index 930440373..bde69350e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectErrors.kt @@ -58,6 +58,11 @@ object ConnectErrors { "launch-not-permitted" -> "This device's access doesn't include launching games — connect to the desktop, " + "or ask the host's owner." + // A host power action (design/host-actions.md) ended the session deliberately. + // Without this arm it falls through to the generic failure, and sleeping your own + // host from the couch reads as a crash. + "host-power" -> + "The host is going to sleep or shutting down — wake it when you want to play again." else -> null } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt index 5b4d3646f..b08d24f5b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt @@ -82,6 +82,10 @@ internal fun ConnectGrid( onSpeedTest: (KnownHost) -> Unit, /** Upload this device's recent log to the host — see the menu row's gate below. */ onSendLogs: (KnownHost) -> Unit, + /** What each paired host last said this device may do TO it, by fingerprint + * (`design/host-actions.md` §7). Absent = no rows. */ + hostActions: Map>, + onHostAction: (KnownHost, HostActions.Action) -> Unit, onCopyLink: (KnownHost, StreamProfile?) -> Unit, onTogglePin: (KnownHost, StreamProfile) -> Unit, /** The experimental game-library toggle — off hides "Browse library…" everywhere. */ @@ -118,6 +122,17 @@ internal fun ConnectGrid( if (pin == null && kh.paired && kh.isOnline(discovered, reachable)) { add(HostMenuItem("Send logs to host") { onSendLogs(kh) }) } + // The host's own actions — sleep, restart, shut it down (`design/host-actions.md` §7), + // the other half of the Wake-on-LAN round trip. Nothing is decided here: the list is + // empty unless the host answered AND this device's access carries the grant, so no row + // appears that the host would refuse. A pinned card is a shortcut to one profile, not a + // second host, so it offers none — same rule as "Send logs" above. + if (pin == null) { + hostActions[kh.fpHex].orEmpty().forEach { a -> + val label = if (a.available) a.label else "${a.label} (unavailable)" + add(HostMenuItem(label) { onHostAction(kh, a) }) + } + } add(HostMenuItem("Copy link") { onCopyLink(kh, pin) }) if (profiles.isEmpty()) return@buildList if (pin != null) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 28ff5b678..43f6b2170 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -53,6 +53,14 @@ import kotlinx.coroutines.withContext */ private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000 +/** + * How long a host's advertised actions stay fresh before this screen asks again — the desktop's + * `pf_client_core::host_actions::TTL`. Long on purpose: what it governs (whether this device + * holds the Host-power grant, whether the box can suspend) changes when an operator edits + * access, not minute to minute, and every refresh is a TLS handshake against an idle host. + */ +private const val HOST_ACTIONS_TTL_MS = 300_000L + /** * A no-PIN "request access" connect in flight — the host being requested (drives the cancelable * "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a @@ -292,6 +300,38 @@ fun ConnectScreen( // A saved host being edited (name / address / port / MAC). var editTarget by remember { mutableStateOf(null) } + // What each paired host says this device may do TO it — sleep, restart, shut it down + // (`design/host-actions.md` §7) — by fingerprint, with the moment we last asked. + // + // Learned on a slow TTL rather than when a menu opens: the row list has to be settled BEFORE + // the menu draws, or rows would appear under a finger already on its way down, and two of + // these rows end whatever is running on that machine. Empty for an older host (no such + // route), an unreachable one, and any device without the grant — the menu simply has no + // power rows then. + var hostActions by remember { mutableStateOf>>(emptyMap()) } + var hostActionsAt by remember { mutableStateOf>(emptyMap()) } + val reachableNow by rememberUpdatedState(reachable) + LaunchedEffect(savedHosts, identity) { + val id = identity ?: return@LaunchedEffect + while (true) { + val now = android.os.SystemClock.elapsedRealtime() + for (kh in savedHosts) { + if (!kh.paired || kh.fpHex.isEmpty()) continue + if (!kh.isOnline(discoveredNow, reachableNow)) continue + if (now - (hostActionsAt[kh.fpHex] ?: 0L) < HOST_ACTIONS_TTL_MS) continue + // Stamp BEFORE the request, so a slow host cannot make every lap ask again. + hostActionsAt = hostActionsAt + (kh.fpHex to now) + val found = withContext(Dispatchers.IO) { + HostActions.list(id, kh.address, kh.effectiveMgmtPort, kh.fpHex) + } + hostActions = hostActions + (kh.fpHex to found) + } + delay(30_000) + } + } + // A destructive host action awaiting its confirmation (restart / shut down). + var confirmAction by remember { mutableStateOf?>(null) } + // Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts", // not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so // it survives a DHCP address change; else by address:port). Mirrors the Apple client. @@ -635,6 +675,40 @@ fun ConnectScreen( if (copied) notice = message else status = message } + // Host actions (`design/host-actions.md` §7) — sleep, restart or shut the host down. The + // menu rows come from what the HOST said it lets this device do, so a device without the + // Host-power grant is offered none; a destructive one still asks first, because losing what + // is running on that machine is not something a mis-tap should be able to do. + fun runHostAction(kh: KnownHost, a: HostActions.Action) { + val id = identity ?: run { + status = "Identity not ready yet — try again in a moment" + return + } + val name = kh.name.ifBlank { kh.address } + notice = "${a.label} — asking $name…" + status = null + // Whatever the host said about itself is about to be wrong: ask again next sweep. + hostActionsAt = hostActionsAt - kh.fpHex + scope.launch { + notice = withContext(Dispatchers.IO) { + HostActions.invoke( + id, kh.address, kh.effectiveMgmtPort, kh.fpHex, name, a.id, a.label, + ) + } + } + } + + fun hostAction(kh: KnownHost, a: HostActions.Action) { + when { + // The host already said it cannot do this right now — say why, rather than send a + // request we know it will refuse. + !a.available -> + notice = a.unavailableReason.ifEmpty { "${a.label} isn't available right now" } + a.danger -> confirmAction = kh to a + else -> runHostAction(kh, a) + } + } + // "Send logs to host" — [SendLogs], the same upload the console's host menu runs. The outcome // is a notice either way (success and failure both name the host), because the row's whole job // is to tell a reporter whether the bundle actually landed. @@ -793,6 +867,8 @@ fun ConnectScreen( onWake = { kh -> wakeHost(kh) }, onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) }, onSendLogs = { kh -> sendLogs(kh) }, + hostActions = hostActions, + onHostAction = { kh, a -> hostAction(kh, a) }, onCopyLink = { kh, pin -> copyLink(kh, pin) }, onTogglePin = { kh, p -> togglePin(kh, p) }, libraryEnabled = settings.libraryEnabled, @@ -828,6 +904,18 @@ fun ConnectScreen( val editSuggestedMacs = editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList() + // A destructive host action's confirmation. Kept here rather than in ConnectPrompts because + // it is a one-question dialog owned by the row that raised it — the same place the row's + // handler lives. + confirmAction?.let { (kh, a) -> + HostActionConfirmDialog( + hostName = kh.name.ifBlank { kh.address }, + action = a, + onConfirm = { confirmAction = null; runHostAction(kh, a) }, + onDismiss = { confirmAction = null }, + ) + } + // Everything that floats above whichever home was drawn, in one place and in one order — see // ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above. ConnectPrompts( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostActions.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostActions.kt new file mode 100644 index 000000000..e2d721da5 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostActions.kt @@ -0,0 +1,121 @@ +package io.unom.punktfunk + +import io.unom.punktfunk.kit.security.ClientIdentity +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * Host actions — sleep, restart or shut down a paired host from this device + * (`design/host-actions.md` §7), over the same mTLS identity the library fetch and the log + * upload use. + * + * The HOST is the only enforcer: [Action.permitted] is what it says about *this* device's + * access, so a device without the Host-power grant is offered nothing rather than shown a row + * that will be refused. Discovery is best-effort by contract — an older host (no such route), + * an unreachable one, or a shape we don't recognise yields an empty list, because a missing + * menu row costs a menu row and a thrown exception costs the screen. + * + * ONE implementation for both Android shells, like [SendLogs]: the Skia console's host menu and + * the touch home's card menu. Wording is the desktop's verbatim (`pf_client_core::host_actions`) + * so a quoted message means the same thing on every client. + * + * Blocking — call it off the main thread. + */ +object HostActions { + + /** One action as the host reports it to THIS device. */ + data class Action( + /** Stable id, the invoke argument (`power.sleep`). */ + val id: String, + /** This client's wording for a known id, else the host's own title. */ + val label: String, + /** Confirm twice — the action loses whatever is running on that machine. */ + val danger: Boolean, + /** The host can run it right now. */ + val available: Boolean, + /** Why not, when it can't. Empty otherwise. */ + val unavailableReason: String, + ) + + /** Local wording for the ids we know; anything else keeps the host's own title, which is + * what lets a later host add an action with no client release. */ + private fun label(id: String, title: String): String = when (id) { + "power.sleep" -> "Sleep host" + "power.reboot" -> "Restart host" + "power.shutdown" -> "Shut down host" + else -> title + } + + /** + * What this host lets this device do to it (`GET /api/v1/actions`). Only the PERMITTED rows + * come back: what a device may not invoke is not its business to render. + */ + fun list(identity: ClientIdentity, addr: String, mgmtPort: Int, fpHex: String): List = + runCatching { + val client = io.unom.punktfunk.kit.library.mtlsHttpClient( + identity.certPem, identity.privateKeyPem, addr, fpHex, + ) + val req = Request.Builder().url("https://$addr:$mgmtPort/api/v1/actions").get().build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@runCatching emptyList() + val arr = JSONObject(resp.body?.string().orEmpty()).optJSONArray("actions") + ?: return@runCatching emptyList() + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + if (!o.optBoolean("permitted")) return@mapNotNull null + val id = o.optString("id") + Action( + id = id, + label = label(id, o.optString("title")), + danger = o.optBoolean("danger"), + available = o.optBoolean("available"), + unavailableReason = o.optString("unavailable_reason"), + ) + } + } + }.getOrDefault(emptyList()) + + /** + * Invoke one action by id (`POST /api/v1/actions/{id}`, empty body) and return the + * user-facing outcome. + * + * A 202 is the last word: the host ends every session and acts about a second later, so + * there is nothing to poll and nothing to undo. A refusal carries the host's own reason + * ("another device is streaming from this host right now"), which tells a person what to do + * where a bare status code would not. + */ + fun invoke( + identity: ClientIdentity, + addr: String, + mgmtPort: Int, + fpHex: String, + hostName: String, + actionId: String, + label: String, + ): String { + val err = runCatching { + val client = io.unom.punktfunk.kit.library.mtlsHttpClient( + identity.certPem, identity.privateKeyPem, addr, fpHex, + ) + val req = Request.Builder() + .url("https://$addr:$mgmtPort/api/v1/actions/$actionId") + // Empty body by design: the id is the whole request, and no request field ever + // reaches the host's privileged path. + .post(ByteArray(0).toRequestBody(null, 0, 0)) + .build() + client.newCall(req).execute().use { resp -> + if (resp.isSuccessful) { + "" + } else { + // The `ApiError` envelope carries the host's sentence; fall back to the code + // only when there isn't one. + runCatching { + JSONObject(resp.body?.string().orEmpty()).optString("error") + }.getOrNull()?.takeIf { it.isNotEmpty() } ?: "the host answered HTTP ${resp.code}" + } + } + }.getOrElse { it.message ?: "the host could not be reached" } + return if (err.isEmpty()) "$hostName: $label — on its way" else "$label failed — $err" + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt index 7ac3211a2..037e940be 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk.console import android.view.InputDevice +import io.unom.punktfunk.HostActions import io.unom.punktfunk.MouseMode import io.unom.punktfunk.Settings import io.unom.punktfunk.StatsVerbosity @@ -33,6 +34,22 @@ internal object ConsoleJson { .put("name", p.name) .put("accent", p.accent ?: JSONObject.NULL) + /** A host's advertised actions in the console model's shape (`HostRow.actions`). */ + private fun actionRows(actions: List?): JSONArray { + val arr = JSONArray() + for (a in actions.orEmpty()) { + arr.put( + JSONObject() + .put("id", a.id) + .put("label", a.label) + .put("danger", a.danger) + .put("available", a.available) + .put("unavailable_reason", a.unavailableReason), + ) + } + return arr + } + /** * The home carousel: saved hosts (name order — Android records carry no last-used time), * each followed by its pinned profile cards, then discovered-but-unsaved hosts. Mirrors @@ -44,6 +61,10 @@ internal object ConsoleJson { discovered: List, reachable: Set, profiles: List, + /** What each paired host last said this device may do TO it, by fingerprint + * (`design/host-actions.md` §7). Absent = no rows, which is also what an older host + * and an ungranted device produce. */ + hostActions: Map> = emptyMap(), ): String { val out = JSONArray() fun advertFor(h: KnownHost): DiscoveredHost? = discovered.firstOrNull { d -> @@ -68,6 +89,7 @@ internal object ConsoleJson { .put("clipboard_sync", h.clipboardSync) .put("last_used", JSONObject.NULL) .put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os) + .put("actions", actionRows(hostActions[h.fpHex])) .put("pin", JSONObject.NULL) .put( "bound_profile", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt index fe235a61b..27c3a00a8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import io.unom.punktfunk.CONNECT_TIMEOUT_MS import io.unom.punktfunk.ConnectErrors +import io.unom.punktfunk.HostActions import io.unom.punktfunk.ProfileStore import io.unom.punktfunk.Settings import io.unom.punktfunk.SettingsStore @@ -99,6 +100,11 @@ object SkiaConsole { private var reachable: Set = emptySet() private var settings: Settings = Settings() + /** What each paired host last said this device may do TO it, by fingerprint, and when we + * last asked — the Android half of the desktop's shared actions cache. Main-thread only. */ + private val hostActions = mutableMapOf>() + private val hostActionsAt = mutableMapOf() + // What the composable hands us while it is on screen. private var onConnected: ((ActiveSession) -> Unit)? = null private var onSettingsChange: ((Settings) -> Unit)? = null @@ -417,12 +423,47 @@ object SkiaConsole { private fun pushHosts() { if (handle == 0L) return + refreshHostActions() NativeBridge.nativeConsoleSetHosts( handle, - ConsoleJson.hostRows(knownHostStore.all(), discovered, reachable, profileStore.all()), + ConsoleJson.hostRows( + knownHostStore.all(), discovered, reachable, profileStore.all(), hostActions, + ), ) } + /** + * Keep each paired, reachable host's advertised actions fresh (`design/host-actions.md` §7), + * mirroring the desktop's `pf_client_core::host_actions::refresh`. + * + * On a slow TTL and never when a menu opens: the row list has to be SETTLED before the menu + * draws, or rows would appear under a cursor already moving toward something else — and two + * of those rows shut a machine down. + */ + private fun refreshHostActions() { + val id = identity ?: return + val now = android.os.SystemClock.elapsedRealtime() + for (h in knownHostStore.all()) { + if (!h.paired || h.fpHex.isEmpty()) continue + val online = discovered.any { + it.fingerprint.equals(h.fpHex, ignoreCase = true) || + (it.host == h.address && it.port == h.port) + } || "${h.address}:${h.port}" in reachable + if (!online) continue + // Stamp BEFORE the request, so a slow host cannot make every push spawn another. + if (now - (hostActionsAt[h.fpHex] ?: 0L) < HOST_ACTIONS_TTL_MS) continue + hostActionsAt[h.fpHex] = now + val (addr, mgmt, fp) = Triple(h.address, h.effectiveMgmtPort, h.fpHex) + ioPool.execute { + val found = HostActions.list(id, addr, mgmt, fp) + main.post { + hostActions[fp] = found + pushHosts() + } + } + } + } + private fun pushKnownHosts() { if (handle == 0L) return NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all())) @@ -573,6 +614,7 @@ object SkiaConsole { c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) } c.optJSONObject("Pair")?.let(::pair) c.optJSONObject("SendLogs")?.let(::sendLogs) + c.optJSONObject("HostAction")?.let(::hostAction) c.optJSONObject("SaveHost")?.let(::saveHost) c.optJSONObject("UpdateHost")?.let(::updateHost) c.optJSONObject("ForgetHost")?.let(::forgetHost) @@ -664,6 +706,26 @@ object SkiaConsole { } } + /** + * Sleep / restart / shut the host down (`design/host-actions.md` §7) — the console already + * confirmed a destructive one twice before raising this, and the host re-checks this + * device's Host-power grant on arrival, so nothing is decided here. + */ + private fun hostAction(c: JSONObject) { + val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex") + val hostName = c.optString("host_name").ifEmpty { addr } + val actionId = c.optString("action_id"); val label = c.optString("label") + val id = identity + if (id == null) { + notice("Identity not ready yet — try again in a moment") + return + } + ioPool.execute { + val message = HostActions.invoke(id, addr, mgmt, fp, hostName, actionId, label) + main.post { notice(message) } + } + } + private fun pair(c: JSONObject) { val addr = c.optString("addr"); val port = c.optInt("port") val pin = c.optString("pin"); val name = c.optString("device_name") @@ -825,4 +887,9 @@ object SkiaConsole { /** The no-PIN request-access park (≥ the host's approval window) — ConnectScreen's figure. */ private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000 + + /** How long a host's advertised actions stay fresh before we ask again — the desktop's + * `pf_client_core::host_actions::TTL`. Long on purpose: what it governs changes when an + * operator edits access, not minute to minute, and each refresh is a TLS handshake. */ + private const val HOST_ACTIONS_TTL_MS = 300_000L } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index e90d3f1db..46daec23b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -107,6 +107,9 @@ struct GamepadHomeView: View { /// The profile catalog — pinned host+profile combos render as their own tiles here, which is /// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4). @ObservedObject private var profiles = ProfileStore.shared + /// What each paired host says this device may do TO it (`design/host-actions.md` §7) — + /// shared with the touch grid, so the two menus cannot disagree about what a host offers. + @ObservedObject private var hostPower = HostPowerStore.shared /// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the /// Settings "Game library" toggle opts out). @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @@ -655,6 +658,10 @@ struct GamepadHomeView: View { store.setPinned(host.id, profileID: profile.id, pinned: false) }, onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil, + // A pinned card is a shortcut to one profile, not a second host, so it carries no + // host actions — the same rule the touch grid and the console menu apply. + hostActions: target.profile == nil ? hostPower.actions(for: host) : [], + onHostAction: { action in await hostPower.invoke(action, on: host) }, close: { if !transitioning { hostOptionsTarget = nil } }, controllerActive: active) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift index 1bd90056d..bdd524a2a 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHostOptionsView.swift @@ -67,6 +67,12 @@ struct GamepadHostOptionsView: View { /// Upload this device's recent log to the host; answers with what to tell the user. nil on an /// unpaired host — the upload rides the pairing, so there is nothing to offer before it. var onSendLogs: (() async -> (ok: Bool, message: String))? + /// What the HOST says this device may do to it — sleep, restart, shut it down + /// (`design/host-actions.md` §7). Empty unless it answered and this device's access carries + /// the grant, so no row here can be refused for permission. + var hostActions: [HostAction] = [] + /// Run one; answers with what to tell the user, like `onSendLogs`. + var onHostAction: ((HostAction) async -> (ok: Bool, message: String))? var close: (() -> Void)? var controllerActive = true @@ -82,8 +88,15 @@ struct GamepadHostOptionsView: View { /// fires on the second. The touch grid removes behind a system confirmation dialog; a console /// is driven by a thumbstick from across a room, which is a good reason to be at least as /// strict as it is, and none at all to be looser. + /// Which row is armed, when the armed one is not Remove: the host's own destructive actions + /// (restart, shut down) take the same two-press treatment. Held as an id, not a flag, so an + /// arming press on one destructive row cannot fire a different one the cursor then reached. + @State private var armedRowID: String? @State private var armed = false @State private var copied = false + /// A host action's outcome, reported in place — this surface has no toast, exactly like + /// `sendLogs` above. + @State private var hostActionState: HostActionState = .idle /// The send-logs row's own state: its label and the detail band report the outcome in place, /// the same way Copy link says "Copied" — this surface has no toast. @State private var sendLogs: SendLogsState = .idle @@ -93,8 +106,15 @@ struct GamepadHostOptionsView: View { case idle, sending, done(ok: Bool, message: String) } + private enum HostActionState: Equatable { + case idle, sending(String), done(ok: Bool, message: String) + } + private enum Action: String { case wake + /// One of the host's own actions; WHICH one rides on the row (`Row.hostAction`), + /// because this enum's raw values are fixed and the host's list is not. + case hostAction case copyLink case edit case forgetPairing @@ -108,7 +128,7 @@ struct GamepadHostOptionsView: View { GamepadMenuList( items: rows, focusID: $focusID, - onActivate: { run($0.action) }, + onActivate: { run($0) }, onBack: { performClose() }, isActive: controllerActive ) { row, focused in @@ -163,6 +183,8 @@ struct GamepadHostOptionsView: View { // two-press rule exists to catch. .onChange(of: focusID) { _, id in if id != Action.remove.rawValue { armed = false } + // Same rule for the host's own destructive rows: leaving one disarms it. + if id != armedRowID { armedRowID = nil } } #if !os(tvOS) .background { @@ -187,7 +209,11 @@ struct GamepadHostOptionsView: View { let label: String var icon: String var isDestructive = false - var id: String { action.rawValue } + /// The host action this row runs, for `action == .hostAction`. + var hostAction: HostAction? + /// Ids must stay unique across the list — the host's rows share one `Action` case, so + /// they key on the action id the host sent. + var id: String { hostAction.map { "hostAction:\($0.id)" } ?? action.rawValue } } private var rows: [Row] { @@ -204,6 +230,18 @@ struct GamepadHostOptionsView: View { if canWake, !isOnline { list.append(Row(action: .wake, label: "Wake host", icon: "power")) } + // …and the other half of that round trip, immediately below it. A destructive one wears + // the same "press again" the Remove row does; an unavailable one stays listed and says + // why when pressed, rather than vanishing. + for a in hostActions { + let rowID = "hostAction:\(a.id)" + var label = a.available ? a.label : "\(a.label) (unavailable)" + if armedRowID == rowID { label = "\(a.label) \u{2014} press again" } + if case .sending(let id) = hostActionState, id == a.id { label = "\(a.label)\u{2026}" } + list.append(Row( + action: .hostAction, label: label, icon: "power", + isDestructive: a.danger, hostAction: a)) + } list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link")) list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil")) if onSendLogs != nil { @@ -231,7 +269,27 @@ struct GamepadHostOptionsView: View { /// The explainer under the list — the same band the settings screen uses, and the only place a /// destructive action can say what it will actually do before it is pressed. private var detail: String { - switch rows.first(where: { $0.id == focusID })?.action { + let focused = rows.first(where: { $0.id == focusID }) + switch focused?.action { + case .hostAction: + guard let a = focused?.hostAction else { return "" } + if case .done(_, let message) = hostActionState { return message } + if !a.available { + return a.unavailableReason ?? "This host can't do that right now." + } + if armedRowID == focused?.id { + return "Press again — this ends every stream and anything running on the host." + } + switch a.id { + case "power.sleep": + return "Put the host to sleep. Wake it again from this menu." + case "power.reboot": + return "Restart the host. Every stream ends and anything running on it stops." + case "power.shutdown": + return "Shut the host down. Wake-on-LAN can start it again if it is armed." + default: + return a.title + } case .wake: return "Send a Wake-on-LAN packet and wait for this host to answer." case .copyLink: @@ -260,7 +318,7 @@ struct GamepadHostOptionsView: View { .init( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select", action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) { - run(row.action) + run(row) } }), .init( glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", @@ -270,8 +328,37 @@ struct GamepadHostOptionsView: View { // MARK: - Actions - private func run(_ action: Action) { - switch action { + private func run(_ row: Row) { + // Moving off an armed row disarms it — an arming press is about THAT row, and must not + // leave a live trigger on the destructive row beside it. + if armedRowID != row.id { armedRowID = nil } + switch row.action { + case .hostAction: + guard let a = row.hostAction, let onHostAction else { return } + // The host already said it cannot do this right now: say why rather than send a + // request we know it will refuse. + guard a.available else { + withAnimation(.smooth(duration: 0.2)) { + hostActionState = .done( + ok: false, + message: a.unavailableReason ?? "\(a.label) isn't available right now.") + } + return + } + // Restart and shut down lose whatever is on that machine, so they arm first. Sleep + // is reversible from this very menu ("Wake host"), so it goes on one press. + if a.danger, armedRowID != row.id { + withAnimation(.smooth(duration: 0.2)) { armedRowID = row.id } + return + } + armedRowID = nil + withAnimation(.smooth(duration: 0.2)) { hostActionState = .sending(a.id) } + Task { + let outcome = await onHostAction(a) + withAnimation(.smooth(duration: 0.2)) { + hostActionState = .done(ok: outcome.ok, message: outcome.message) + } + } case .wake: onWake() performClose() diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 30ef84397..e8fffdfdc 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -47,6 +47,11 @@ struct HomeView: View { @State private var editTarget: StoredHost? /// The outcome of the last "Send Logs to Host" — drives its alert. @State private var sendLogsResult: (ok: Bool, message: String)? + /// What each paired host says this device may do to it (`design/host-actions.md` §7). + @StateObject private var hostPower = HostPowerStore.shared + /// A destructive host action awaiting its confirmation. + @State private var confirmHostAction: PendingHostAction? + @State private var hostActionResult: (ok: Bool, message: String)? // How this device shows its own list. `.added` is the default because it is what the grid // did before it could sort at all — an update should not rearrange anyone's hosts. @AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue @@ -139,6 +144,13 @@ struct HomeView: View { .task { while !Task.isCancelled { await store.refreshReachability(discovery: discovery) + // Keep each reachable paired host's advertised actions warm on the same + // beat, so a card's menu is BUILT from a settled answer rather than one + // arriving while the menu is open. TTL-gated inside, so this costs nothing + // on an ordinary lap. + for host in store.hosts where host.pinnedSHA256 != nil && isOnline(host) { + hostPower.refresh(host) + } try? await Task.sleep(for: .seconds(10)) } } @@ -206,6 +218,39 @@ struct HomeView: View { } message: { Text(sendLogsResult?.message ?? "") } + // A destructive host action asks first: restart and shut down lose whatever is running + // on that machine, and a mis-tap on a phone must not be able to do that. Sleep is + // reversible from the same menu ("Wake Host"), so it never reaches here. + .alert( + confirmHostAction.map { "\($0.action.label)?" } ?? "", + isPresented: Binding( + get: { confirmHostAction != nil }, + set: { if !$0 { confirmHostAction = nil } }) + ) { + Button("Cancel", role: .cancel) { confirmHostAction = nil } + if let pending = confirmHostAction { + Button(pending.action.label, role: .destructive) { + confirmHostAction = nil + runHostAction(pending.action, on: pending.host) + } + } + } message: { + Text( + confirmHostAction.map { + "This ends every stream from \($0.host.displayName) and anything running " + + "on it. You'll need to wake or start it again." + } ?? "") + } + .alert( + hostActionResult?.ok == true ? "On Its Way" : "Couldn't Do That", + isPresented: Binding( + get: { hostActionResult != nil }, + set: { if !$0 { hostActionResult = nil } }) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(hostActionResult?.message ?? "") + } #if os(macOS) .frame(minWidth: 480, minHeight: 360) #endif @@ -306,10 +351,34 @@ struct HomeView: View { onEdit: { editTarget = host }, onSendLogs: host.pinnedSHA256 != nil ? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil, + // A pinned card is a shortcut to one profile, not a second host, so it carries no + // host actions — the same rule the console's menu applies. + hostActions: pinned == nil ? hostPower.actions(for: host) : [], + onHostAction: { action in hostAction(action, on: host) }, profileMenu: profileMenu(for: host), pinnedProfile: pinned) } + /// A host action picked from a card's menu: explain an unavailable one, confirm a + /// destructive one, run the rest. + private func hostAction(_ action: HostAction, on host: StoredHost) { + guard action.available else { + hostActionResult = ( + false, + action.unavailableReason ?? "\(action.label) isn't available right now.") + return + } + if action.danger { + confirmHostAction = PendingHostAction(host: host, action: action) + } else { + runHostAction(action, on: host) + } + } + + private func runHostAction(_ action: HostAction, on host: StoredHost) { + Task { hostActionResult = await hostPower.invoke(action, on: host) } + } + /// The profile affordances every host card carries (§5.2/§5.2a). private func profileMenu(for host: StoredHost) -> HostProfileMenu { HostProfileMenu( diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index 27dbbc8d6..6a208beab 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -140,6 +140,13 @@ struct HostCardView: View { /// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired — /// the upload is authenticated by the pairing, so there is nothing to offer before it. var onSendLogs: (() -> Void)? = nil + /// What this host says this device may do TO it — sleep, restart, shut it down + /// (`design/host-actions.md` §7). Empty on every surface that doesn't offer them, and on + /// every host that hasn't answered or hasn't granted them. + var hostActions: [HostAction] = [] + /// Run one of the above. `nil` alongside a non-empty `hostActions` would be a bug, so the + /// rows render disabled in that case rather than silently doing nothing. + var onHostAction: ((HostAction) -> Void)? = nil /// This card's profile affordances — nil on surfaces that don't offer them. var profileMenu: HostProfileMenu? = nil /// Set on a PINNED card: the profile this card connects with. nil = the host's primary card, @@ -261,6 +268,17 @@ struct HostCardView: View { if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake { Button("Wake Host", systemImage: "power", action: onWake) } + // …and the other half of that round trip: what the HOST says this device may do to + // it. Empty unless it answered and this device's access carries the grant, so no row + // here can be refused for permission. A destructive one confirms in the caller. + ForEach(hostActions) { action in + Button( + action.available ? action.label : "\(action.label) (Unavailable)", + systemImage: "power", + role: action.danger ? .destructive : nil + ) { onHostAction?(action) } + .disabled(onHostAction == nil) + } if host.pinnedSHA256 != nil { // Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via // PIN (unless the host advertises pair=optional). Wording reflects that. diff --git a/clients/apple/Sources/PunktfunkClient/Support/HostPower.swift b/clients/apple/Sources/PunktfunkClient/Support/HostPower.swift new file mode 100644 index 000000000..bd99eb69a --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Support/HostPower.swift @@ -0,0 +1,98 @@ +// Host actions — sleep, restart or shut down a paired host from this device +// (`design/host-actions.md` §7), the other half of the Wake-on-LAN round trip. The Apple port of +// the Gaming Mode console's `ConsoleCmd::HostAction` (clients/session/src/console.rs), same +// wording on success. +// +// The HOST is the only enforcer: `HostAction.permitted` is what it says about THIS device's +// access, so a device without the Host-power grant is offered nothing rather than shown a row +// that will be refused. Discovery is kept on a slow TTL so a menu's rows are settled before it +// draws — rows that appear under a finger already on its way down would be a hazard when two of +// them end whatever is running on that machine. + +import Foundation +import PunktfunkKit + +private let log = ClientLog(category: "power") + +/// A destructive host action waiting on its confirmation — which host, and which verb. +struct PendingHostAction: Identifiable { + let host: StoredHost + let action: HostAction + var id: String { "\(host.id.uuidString):\(action.id)" } +} + +/// The per-host answer cache, shared by the touch card menu and the gamepad options view. +/// +/// `@MainActor` and observable: the menus read `actions(for:)` while drawing, and a refresh that +/// lands republishes them. One cache for both surfaces, so the two menus cannot disagree about +/// what a host offers. +@MainActor +final class HostPowerStore: ObservableObject { + static let shared = HostPowerStore() + + /// How long an answer stays fresh. Long on purpose: what it governs (whether this device + /// holds the grant, whether the box can suspend) changes when an operator edits access, not + /// minute to minute, and every refresh is a TLS handshake against an otherwise idle host. + private static let ttl: TimeInterval = 300 + + @Published private var byHost: [String: [HostAction]] = [:] + private var askedAt: [String: Date] = [:] + + /// What `host` last said this device may do to it. Empty until a refresh answers, and empty + /// for an older host, an unreachable one, or a device without the grant. + func actions(for host: StoredHost) -> [HostAction] { byHost[host.id.uuidString] ?? [] } + + /// Ask again unless the cached answer is still fresh. Cheap and idempotent — call it from + /// whatever the surface already does on appear or on a refresh tick. + func refresh(_ host: StoredHost) { + let key = host.id.uuidString + // Stamp BEFORE the request, so a slow or hanging host cannot make every pass ask again. + if let at = askedAt[key], Date().timeIntervalSince(at) < Self.ttl { return } + guard let pin = host.pinnedSHA256, + let identity = (try? ClientIdentityStore.shared.load())?.identity + else { return } + askedAt[key] = Date() + Task { @MainActor in + let found = await LibraryClient.actions( + address: host.address, port: host.effectiveMgmtPort, + certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin) + byHost[key] = found + } + } + + /// Forget what this host said — call it right after invoking an action, because whatever it + /// said is about to be wrong. Without this, a menu goes on offering "Sleep Host" for a + /// machine that is already asleep until the TTL lapses. + func invalidate(_ host: StoredHost) { + let key = host.id.uuidString + byHost[key] = [] + askedAt[key] = nil + } + + /// Run one action against `host`. Never throws: the caller shows `message` either way. + /// + /// Success means the host ACCEPTED it — it now ends every session and acts about a second + /// later, so this is the last word this device will get on the subject. + func invoke(_ action: HostAction, on host: StoredHost) async -> (ok: Bool, message: String) { + guard let identity = (try? ClientIdentityStore.shared.load())?.identity else { + return (false, "Connect to this host once first — host actions use the identity " + + "created on the first connect.") + } + guard let pin = host.pinnedSHA256 else { + return (false, "Pair with \(host.displayName) first — host actions only go to a " + + "paired host.") + } + invalidate(host) + do { + try await LibraryClient.invokeAction( + id: action.id, address: host.address, port: host.effectiveMgmtPort, + certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin) + log.info("host action \(action.id, privacy: .public) accepted by \(host.displayName, privacy: .public)") + return (true, "\(host.displayName): \(action.label) — on its way.") + } catch { + let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + log.warning("host action \(action.id, privacy: .public) refused by \(host.displayName, privacy: .public): \(why, privacy: .public)") + return (false, "\(action.label) failed — \(why)") + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index 9aaecd464..d967e1049 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -168,6 +168,45 @@ public struct RunningGame: Codable, Hashable, Sendable { public var isUp: Bool { state != "exited" } } +/// One action a host offers THIS device, from `/api/v1/actions` (`design/host-actions.md` §3.2) +/// — v1: sleep, restart, shut down. +/// +/// Unknown ids are expected and fine: the client renders ``title`` verbatim for anything it has +/// no local wording for, which is what lets a later host add actions with no client release. +public struct HostAction: Codable, Hashable, Sendable, Identifiable { + /// Stable id: `power.sleep`, `power.reboot`, `power.shutdown` today. + public var id: String + /// The host's own display title — the fallback label for an id this client doesn't know. + public var title: String + /// Action group (`power` for the built-ins). + public var group: String + /// Confirm before running: the action loses whatever is on that machine (restart, shut down). + public var danger: Bool + /// Whether the host can run it right now (a machine that cannot suspend, a foreign inhibitor). + public var available: Bool + /// Why not, when it can't — shown rather than hidden, so "greyed out" always has a reason. + public var unavailableReason: String? + /// Whether THIS device's access covers it (the host's Host-power grant). The client only ever + /// keeps the permitted ones. + public var permitted: Bool + + private enum CodingKeys: String, CodingKey { + case id, title, group, danger, available, permitted + case unavailableReason = "unavailable_reason" + } + + /// This client's wording for a known id, else the host's own title — so a familiar action is + /// worded the way the rest of this app words it, without hiding an unfamiliar one. + public var label: String { + switch id { + case "power.sleep": return "Sleep Host" + case "power.reboot": return "Restart Host" + case "power.shutdown": return "Shut Down Host" + default: return title + } + } +} + /// Stateless fetcher for a host's library. public enum LibraryClient { /// `GET https://
:/api/v1/library`, authenticated by **mTLS**: the client @@ -269,6 +308,69 @@ public enum LibraryClient { } } + /// What this host lets THIS device do to it — sleep, restart, shut it down + /// (`design/host-actions.md` §7) — from `GET /api/v1/actions`. + /// + /// Only the PERMITTED rows come back: the host is the only judge of whether this device's + /// access carries the Host-power grant, and a row it would refuse is not this client's to + /// render. Best-effort by contract, like ``running(address:port:certPEM:keyPEM:hostFingerprint:)`` + /// — an older host (no such route), an unreachable one, or a shape we don't recognise yields + /// an empty list. A missing menu row costs a menu row; a thrown error would cost the screen. + public static func actions( + address: String, + port: UInt16 = punktfunkDefaultMgmtPort, + certPEM: String, + keyPEM: String, + hostFingerprint: Data? + ) async -> [HostAction] { + guard let identity = try? clientIdentity(certPEM: certPEM, keyPEM: keyPEM), + let response = try? await send( + path: "/api/v1/actions", address: address, port: port, + identity: identity, hostFingerprint: hostFingerprint), + response.status == 200, + let list = try? JSONDecoder().decode(HostActionList.self, from: response.body) + else { return [] } + return list.actions.filter(\.permitted) + } + + /// Invoke one host action by id (`POST /api/v1/actions/{id}`, empty body). + /// + /// Returning normally means the host ACCEPTED it (202) — it now ends every session and acts + /// about a second later, so this is the last word the client will get. A refusal throws + /// with the host's own sentence ("another device is streaming from this host right now"), + /// which tells a person what to do where a bare status code would not. + /// + /// The body stays empty by design: the id is the whole request, and no request field ever + /// reaches the host's privileged path. + public static func invokeAction( + id: String, + address: String, + port: UInt16 = punktfunkDefaultMgmtPort, + certPEM: String, + keyPEM: String, + hostFingerprint: Data + ) async throws { + let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM) + let escaped = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response = try await send( + path: "/api/v1/actions/\(escaped)", address: address, port: port, + identity: identity, hostFingerprint: hostFingerprint, + body: (Data(), "application/json")) + switch response.status { + case 200, 202: + return + case 401, 403: + throw LibraryError.unauthorized + default: + // The `ApiError` envelope carries the host's reason; prefer it over the code. + let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any] + if let why = json?["error"] as? String, !why.isEmpty { + throw LibraryError.unreachable(why) + } + throw LibraryError.http(response.status) + } + } + /// Just the slice of `/status` this client reads. Everything else on that payload is the /// operator console's business, and decoding only what we use keeps an unrelated schema change /// on the host from breaking the library screen. @@ -276,6 +378,10 @@ public enum LibraryClient { var games: [RunningGame]? } + private struct HostActionList: Decodable { + var actions: [HostAction] + } + /// `https://addr:port`, IPv6 literals bracketed — the mirror of the Rust client's `base_url`. static func baseURL(address: String, port: UInt16) -> String { let bare = address.hasPrefix("[") && address.hasSuffix("]") diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 18292c5d7..1783276e7 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -105,6 +105,10 @@ public enum HostRejection: Sendable { /// The Hello asked to launch a title but this device's grants exclude `LAUNCH` — refused /// at the handshake so the user gets a sentence, not a bare desktop they didn't ask for. case launchNotPermitted + /// A host power action (`design/host-actions.md`) is ending every session: the host is + /// going to sleep or shutting down, deliberately. Without this case the close reads as a + /// transport failure, and sleeping your own host from the couch looks like a crash. + case hostPower init?(status: Int32) { switch status { @@ -119,6 +123,7 @@ public enum HostRejection: Sendable { case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy case PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED.rawValue: self = .accessExpired case PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED.rawValue: self = .launchNotPermitted + case PUNKTFUNK_STATUS_REJECTED_HOST_POWER.rawValue: self = .hostPower default: return nil } } @@ -154,6 +159,9 @@ public enum HostRejection: Sendable { case .launchNotPermitted: return "This device isn't permitted to launch games on the host — connect " + "to the desktop instead, or ask the owner to allow launching." + case .hostPower: + return "The host is going to sleep or shutting down — wake it when you want " + + "to play again." } } } diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 1735499b3..81e874ad3 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -159,6 +159,15 @@ pub enum AppMsg { /// outcome lands as a Toast either way. The mgmt port rides along, resolved like /// OpenLibrary's. SendLogs(ConnectRequest, Option), + /// Run one of the host's own actions — sleep / restart / shut down it + /// (`design/host-actions.md` §7). `danger` asks first; the outcome is a toast. + HostAction { + req: ConnectRequest, + mgmt: Option, + action_id: String, + label: String, + danger: bool, + }, /// The speed-test dialog resolved (either way) — release `busy`. SpeedTestDone, ShowPreferences, @@ -271,6 +280,19 @@ impl SimpleComponent for AppModel { HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req), HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt), HostsOutput::SendLogs(req, mgmt) => AppMsg::SendLogs(req, mgmt), + HostsOutput::HostAction { + req, + mgmt, + action_id, + label, + danger, + } => AppMsg::HostAction { + req, + mgmt, + action_id, + label, + danger, + }, HostsOutput::Toast(msg) => AppMsg::Toast(msg), }); @@ -466,6 +488,76 @@ impl SimpleComponent for AppModel { }) .ok(); } + AppMsg::HostAction { + req, + mgmt, + action_id, + label, + danger, + } => { + let mgmt = mgmt.unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT); + // Restart and shut down lose whatever is running on that machine, so they ask + // first — the same treatment Forget gets. Sleep is reversible from the same + // menu ("Wake host"), so it goes straight through. + if danger { + let dialog = adw::AlertDialog::new( + Some(&format!("{label}?")), + Some(&format!( + "This ends every stream from {} and anything running on it. \ + You'll need to wake or start it again.", + req.name + )), + ); + dialog.add_responses(&[("cancel", "Cancel"), ("go", &label)]); + dialog.set_response_appearance("go", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let out = sender.input_sender().clone(); + let (req, action_id, label) = (req.clone(), action_id.clone(), label.clone()); + dialog.connect_response(Some("go"), move |_, _| { + out.send(AppMsg::HostAction { + req: req.clone(), + mgmt: Some(mgmt), + action_id: action_id.clone(), + label: label.clone(), + // Asked and answered. + danger: false, + }) + .ok(); + }); + dialog.present(Some(&self.window)); + return; + } + // Blocking network on a worker, outcome as a toast — the SendLogs recipe. A + // 202 is the last word: the host ends every session and acts a second later, + // so there is nothing to poll and nothing to undo. + let identity = self.identity.clone(); + let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32); + if let Some(fp) = req.fp_hex.as_deref() { + // Whatever the host said about itself is about to be wrong. + pf_client_core::host_actions::invalidate(fp); + } + self.toast(&format!("{label} — asking {}…", req.name)); + let out = sender.input_sender().clone(); + std::thread::Builder::new() + .name("punktfunk-hostaction".into()) + .spawn(move || { + let msg = match pf_client_core::host_actions::invoke( + &req.addr, mgmt, &identity, pin, &action_id, + ) { + Ok(()) => { + tracing::info!(host = %req.name, action = %action_id, "host action accepted"); + format!("{}: {label} — on its way", req.name) + } + Err(e) => { + tracing::warn!(host = %req.name, action = %action_id, error = %e, "host action refused"); + format!("{label} failed — {e}") + } + }; + let _ = out.send(AppMsg::Toast(msg)); + }) + .ok(); + } AppMsg::SpeedTestDone => self.busy = false, AppMsg::OpenLibrary(req, mgmt_port) => { crate::ui_library::open(self, &sender, req, mgmt_port); diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index e69472764..48b28ece8 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -96,6 +96,16 @@ pub enum CardOutput { Library(ConnectRequest), /// Upload this device's recent log ring to the host (`logring::send_to_host`). SendLogs(ConnectRequest), + /// Run one of the host's OWN actions — sleep / restart / shut down it + /// (`design/host-actions.md` §7). `label` is what the menu called it, so the confirmation + /// and the toast say the same words the row did. + HostAction { + req: ConnectRequest, + action_id: String, + label: String, + /// Ask before running: the action loses whatever is on that machine. + danger: bool, + }, /// Open the host edit sheet (name, profile binding, pinned cards, clipboard). Edit { fp_hex: String, @@ -111,6 +121,9 @@ pub enum CardOutput { }, /// Put this card's `punktfunk://` URL on the clipboard. CopyLink(String), + /// A one-line message for the window's toast overlay — a card that has something to say + /// and nothing to do (a host action the host has already told us it cannot run). + Toast(String), /// Write a desktop entry that launches this card's URL. CreateShortcut { label: String, @@ -372,6 +385,38 @@ impl relm4::factory::FactoryComponent for HostCard { }), ); } + // The host's own actions, one registered action per offered row. Read from + // the shared cache the hosts page keeps warm, so the menu's rows and these + // handlers are built from the SAME answer — a menu whose rows outlived their + // handlers would run the wrong verb, and two of these verbs are irreversible. + let host_actions = pf_client_core::host_actions::cached(&k.fp_hex); + for (i, a) in host_actions.iter().enumerate() { + let (req, id, label, danger) = + (req.clone(), a.id.clone(), a.label().to_string(), a.danger); + let available = a.available; + let reason = a.unavailable_reason.clone().unwrap_or_default(); + add( + &format!("action{i}"), + Box::new(move || { + if available { + CardOutput::HostAction { + req: req.clone(), + action_id: id.clone(), + label: label.clone(), + danger, + } + } else { + // The host already said it cannot do this right now; say why + // rather than send a request we know it will refuse. + CardOutput::Toast(if reason.is_empty() { + format!("{label} isn't available right now") + } else { + reason.clone() + }) + } + }), + ); + } // "Copy link" / "Create shortcut…": the self-emitted URL for this card, which // is what an external tool (a Playnite entry, a Stream Deck macro) is // configured with. It carries the stable id AND host+fp, so it still resolves @@ -540,6 +585,20 @@ impl relm4::factory::FactoryComponent for HostCard { if !online && !k.mac.is_empty() { look.append(Some("Wake host"), Some("card.wake")); } + // …and the other half of that round trip: whatever this host last said it + // lets this device do to it (sleep, restart, shut down). Nothing is decided + // here — the list is empty unless the host answered and this device's + // access carries the grant, so no row ever appears that the host would + // refuse. Indexed actions rather than fixed labels: a later host can add + // one and this menu renders it with no client release. + for (i, a) in host_actions.iter().enumerate() { + let label = if a.available { + a.label().to_string() + } else { + format!("{} (unavailable)", a.label()) + }; + look.append(Some(&label), Some(&format!("card.action{i}"))); + } menu.append_section(None, &look); let links = gio::Menu::new(); @@ -747,6 +806,15 @@ pub enum HostsOutput { Library(ConnectRequest, Option), /// With the mgmt port resolved the same way as [`HostsOutput::Library`]'s. SendLogs(ConnectRequest, Option), + /// Run one of the host's own actions (`design/host-actions.md` §7) — same mgmt-port + /// resolution as the two above. + HostAction { + req: ConnectRequest, + mgmt: Option, + action_id: String, + label: String, + danger: bool, + }, } impl SimpleComponent for HostsPage { @@ -1042,6 +1110,24 @@ impl SimpleComponent for HostsPage { let mgmt = self.mgmt_port_for(&req); let _ = sender.output(HostsOutput::SendLogs(req, mgmt)); } + CardOutput::HostAction { + req, + action_id, + label, + danger, + } => { + let mgmt = self.mgmt_port_for(&req); + let _ = sender.output(HostsOutput::HostAction { + req, + mgmt, + action_id, + label, + danger, + }); + } + CardOutput::Toast(msg) => { + let _ = sender.output(HostsOutput::Toast(msg)); + } CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name), CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name), CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()), @@ -1123,7 +1209,8 @@ impl HostsPage { // — the last one not cosmetic, since a host that moved off 47990 loses its // library the moment mDNS is unavailable and the advert is the only place the // real port ever lived. - if let Some(a) = self.adverts.values().find(|a| matches(k, a)) { + let advert = self.adverts.values().find(|a| matches(k, a)); + if let Some(a) = advert { crate::trust::learn_from_advert( &k.fp_hex, &k.addr, @@ -1133,6 +1220,17 @@ impl HostsPage { a.mgmt_port, ); } + // Keep this host's advertised actions warm, so the card's menu is built from a + // settled answer rather than one that arrives while the menu is open. Gated on + // the TTL inside, so an ordinary refresh costs nothing. Same three rungs for + // the port as everything else here: live advert, then the stored one, then the + // default. + if k.paired && online { + let mgmt = advert + .and_then(|a| a.mgmt_port) + .unwrap_or_else(|| k.effective_mgmt_port()); + pf_client_core::host_actions::refresh(&k.addr, mgmt, &k.fp_hex); + } saved.push_back(HostCard { connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()), kind: CardKind::Saved { diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 779c6a529..5c7b1515e 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -85,6 +85,9 @@ pub fn run(target: Option<&str>) -> u8 { clipboard_sync: k.is_some_and(|h| h.clipboard_sync), last_used: k.and_then(|h| h.last_used), os: k.map(|h| h.os.clone()).unwrap_or_default(), + // A seed row is a host nobody has reached yet; the refresh tick fills this in + // once it is paired and answering. + actions: Vec::new(), pin: None, bound_profile: None, }; @@ -340,6 +343,7 @@ fn fake_host_row() -> HostRow { clipboard_sync: false, last_used: None, os: "linux/arch/steamos".into(), + actions: Vec::new(), pin: None, bound_profile: None, } @@ -437,6 +441,7 @@ impl ServiceState { if self.last_probe.elapsed() >= Duration::from_secs(10) { self.last_probe = Instant::now(); self.sweep(); + self.refresh_actions(); } self.console.set_hosts(self.rows()); @@ -524,6 +529,41 @@ impl ServiceState { }) .ok(); } + ConsoleCmd::HostAction { + addr, + mgmt, + fp_hex, + host_name, + action_id, + label, + } => { + // Same lane and budgets as SendLogs above, and the same worker-thread reason. + // A 202 is the last word: the host ends every session and acts a second later, + // so there is nothing to poll and nothing to undo — say it plainly and let the + // tile go dark on its own. + let identity = self.identity.clone(); + let pin = trust::parse_hex32(&fp_hex); + let console = self.console.clone(); + // Whatever the host said about itself is about to be wrong. + pf_client_core::host_actions::invalidate(&fp_hex); + std::thread::Builder::new() + .name("punktfunk-hostaction".into()) + .spawn(move || { + match pf_client_core::host_actions::invoke( + &addr, mgmt, &identity, pin, &action_id, + ) { + Ok(()) => { + tracing::info!(host = %host_name, action = %action_id, "host action accepted"); + console.set_notice(format!("{host_name}: {label} — on its way")); + } + Err(e) => { + tracing::warn!(host = %host_name, action = %action_id, error = %e, "host action refused"); + console.set_notice(format!("{label} failed — {e}")); + } + } + }) + .ok(); + } ConsoleCmd::Pair { addr, port, @@ -765,6 +805,17 @@ impl ServiceState { .ok(); } + /// Keep every paired, reachable host's advertised actions fresh (the shared TTL'd cache in + /// `pf_client_core::host_actions`). Idempotent and cheap — it only reaches the network when + /// an entry has actually lapsed. + fn refresh_actions(&self) { + for r in self.rows() { + if r.paired && r.online && r.pin.is_none() { + pf_client_core::host_actions::refresh(&r.addr, r.mgmt_port, &r.fp_hex); + } + } + } + fn advertised(&self, row: &HostRow) -> bool { self.discovered.values().any(|d| { (!row.fp_hex.is_empty() && d.fp_hex == row.fp_hex) @@ -839,6 +890,20 @@ impl ServiceState { .filter(|d| !d.os.is_empty()) .map(|d| d.os.clone()) .unwrap_or_else(|| h.os.clone()), + // Whatever this host last told us it lets this device do to it. Empty + // until the first refresh answers, and empty forever for a host that has + // no such route or a device without the grant — the menu simply has no + // power rows then. + actions: pf_client_core::host_actions::cached(&h.fp_hex) + .into_iter() + .map(|a| pf_console_ui::HostAction { + label: a.label().to_string(), + id: a.id, + danger: a.danger, + available: a.available, + unavailable_reason: a.unavailable_reason.unwrap_or_default(), + }) + .collect(), pin: None, bound_profile: h .profile_id @@ -895,6 +960,8 @@ impl ServiceState { clipboard_sync: false, last_used: None, os: d.os.clone(), + // Discovered but unsaved: not paired, so there is nothing it would let us do. + actions: Vec::new(), pin: None, bound_profile: None, }) diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 4dc6fd805..6fa5d2316 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -21,6 +21,24 @@ const MENU_SPEED: &str = "Test network speed\u{2026}"; /// and an offline host could only ever report an error. const MENU_SEND_LOGS: &str = "Send logs to host"; const MENU_WAKE: &str = "Wake host"; +/// The host's OWN actions — sleep / restart / shut down it (`design/host-actions.md` §7) — +/// each prefixed so the shared click callback can tell them from the fixed entries and recover +/// which one was picked. The rows come from what the HOST said it lets this device do, so a +/// device without the Host-power grant sees none, and a later host can add one without a +/// client release. Same shape as [`MENU_PIN`]'s dynamic family, for the same reason. +const MENU_HOST_ACTION: &str = "\u{23fb} "; + +/// One host action's menu label. Used to BUILD the row and to recognise it again in the click +/// callback — one function, so the two can never disagree, and the match stays exact rather +/// than a prefix test that two similarly-named actions could both satisfy. +#[cfg(windows)] +fn host_action_label(a: &pf_client_core::host_actions::ActionInfo) -> String { + format!( + "{MENU_HOST_ACTION}{}{}", + a.label(), + if a.available { "" } else { " (unavailable)" } + ) +} /// One entry for every per-host property (name, address, MAC, clipboard sharing) — the /// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones /// that matter. @@ -724,8 +742,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { ); } let can_wake = !online && !k.mac.is_empty(); + // What this host last said it lets this device do to it. Kept warm here — on the + // list's own refresh, gated by the cache's TTL — so the menu is BUILT from a + // settled answer: rows that appeared while a menu was open would land under a + // cursor already moving, and two of these rows shut a machine down. + if k.paired && online { + pf_client_core::host_actions::refresh( + &k.addr, + target + .mgmt_port + .unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT), + &k.fp_hex, + ); + } + let host_actions = pf_client_core::host_actions::cached(&k.fp_hex); let menu = { let (svc, target) = (props.svc.clone(), target.clone()); + let click_actions = host_actions.clone(); let (sf, sr) = (set_forget.clone(), set_rename.clone()); let (fp, name) = (k.fp_hex.clone(), k.name.clone()); let menu_profiles = profiles.clone(); @@ -771,6 +804,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { if can_wake { items.push(menu_item(MENU_WAKE)); } + // …and the other half of that round trip, from the shared cache the + // host list keeps warm. Empty unless the host answered AND this + // device's access carries the grant, so no row here can be refused + // for permission. + for a in &host_actions { + items.push(menu_item(host_action_label(a))); + } items.push(menu_separator()); items.push(menu_item(MENU_COPY_LINK)); @@ -799,6 +839,64 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { items }) .on_item_clicked(move |item: String| match item.as_str() { + // The host's own actions are dynamic too, and matched by prefix ahead + // of the fixed entries. The label is recovered back to an id through + // the SAME list the rows were built from, so a menu whose rows outlived + // their handlers can never run a different verb than the one clicked — + // which matters here more than anywhere else in this menu. + _ if item.starts_with(MENU_HOST_ACTION) => { + let Some(a) = + click_actions.iter().find(|a| host_action_label(a) == item) + else { + return; + }; + let set_status = svc.set_status.clone(); + let (action_id, label) = (a.id.clone(), a.label().to_string()); + if !a.available { + // The host already said it cannot do this right now. + set_status.call( + a.unavailable_reason + .clone() + .unwrap_or_else(|| format!("{label} isn't available")), + ); + return; + } + let identity = svc.ctx.identity.clone(); + let target = target.clone(); + if let Some(fp) = target.fp_hex.as_deref() { + // Whatever the host said about itself is about to be wrong. + pf_client_core::host_actions::invalidate(fp); + } + set_status.call(format!("{label} — asking {}…", target.name)); + let _ = std::thread::Builder::new() + .name("punktfunk-hostaction".into()) + .spawn(move || { + let pin = target + .fp_hex + .as_deref() + .and_then(crate::trust::parse_hex32); + let mgmt = target + .mgmt_port + .unwrap_or(pf_client_core::library::DEFAULT_MGMT_PORT); + let msg = match pf_client_core::host_actions::invoke( + &target.addr, + mgmt, + &identity, + pin, + &action_id, + ) { + Ok(()) => { + tracing::info!(host = %target.name, action = %action_id, "host action accepted"); + format!("{}: {label} — on its way", target.name) + } + Err(e) => { + tracing::warn!(host = %target.name, action = %action_id, error = %e, "host action refused"); + format!("{label} failed — {e}") + } + }; + set_status.call(msg); + }); + } // The profile items are dynamic, so they are matched by prefix before // the fixed ones. _ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => { diff --git a/crates/pf-client-core/src/host_actions.rs b/crates/pf-client-core/src/host_actions.rs new file mode 100644 index 000000000..19b5b02f9 --- /dev/null +++ b/crates/pf-client-core/src/host_actions.rs @@ -0,0 +1,272 @@ +//! Host actions from a client (`design/host-actions.md` §7): discovering what the paired host +//! offers — v1, sleep / restart / shut down — and invoking one by id. +//! +//! Same lane, same trust, same agent as the library browse ([`crate::library`]): TLS client auth +//! with the device identity, host pinned by fingerprint, over `mgmt_port`. Nothing new is asked +//! of the transport, and the HOST is the only enforcer — [`ActionInfo::permitted`] is what the +//! host says about *this* device's grants, so the client renders honestly instead of offering a +//! row that will 403. +//! +//! Both calls work OUT of session, which is the point: "sleep the host" belongs on a host tile +//! at the end of an evening, not only mid-stream. + +use serde::Deserialize; + +/// One action as the host reports it to THIS caller (`GET /api/v1/actions`). +/// +/// Unknown ids are expected and fine — a client renders [`Self::title`] verbatim for anything it +/// has no local string for, which is what lets a later host add actions with no client release. +#[derive(Clone, Debug, Deserialize)] +pub struct ActionInfo { + /// Stable id: `power.sleep`, `power.reboot`, `power.shutdown` today. + pub id: String, + /// The host's own display title — the fallback label for an id this client doesn't know. + #[serde(default)] + pub title: String, + /// Action group (`power` for the built-ins). + #[serde(default)] + pub group: String, + /// Confirm twice before running it: the action loses state (restart, shut down). + #[serde(default)] + pub danger: bool, + /// Whether the host can run it at all right now (a machine that cannot suspend, a foreign + /// inhibitor, a missing group membership). + #[serde(default)] + pub available: bool, + /// Why not, when `available` is false — shown rather than hidden, so "greyed out" always has + /// a reason attached. + #[serde(default)] + pub unavailable_reason: Option, + /// Whether THIS device's access covers it (the host's Host-power grant). + #[serde(default)] + pub permitted: bool, +} + +impl ActionInfo { + /// Offer this row at all? Actions the device may not invoke are hidden (that is the access + /// level talking, and a permanently dead row is noise); actions it may invoke but the host + /// cannot run right now are SHOWN, disabled, with [`Self::unavailable_reason`]. + pub fn offerable(&self) -> bool { + self.permitted + } + + /// The client's own label for a known id, else the host's title. Keeps a familiar action + /// worded the way the rest of this client words it, without hiding an unfamiliar one. + pub fn label(&self) -> &str { + match self.id.as_str() { + "power.sleep" => "Sleep host", + "power.reboot" => "Restart host", + "power.shutdown" => "Shut down host", + _ => &self.title, + } + } +} + +#[cfg(any(target_os = "linux", windows))] +#[derive(Deserialize)] +struct ActionList { + #[serde(default)] + actions: Vec, +} + +/// What this host offers this device, from `GET /api/v1/actions`. +/// +/// **Best-effort by contract**, exactly like [`crate::library::fetch_running`]: an older host +/// (which has no such route), an unreachable one, or a shape we don't recognize yields an empty +/// list rather than an error. A missing row costs a menu entry; failing a host card over it +/// would cost the screen. +#[cfg(any(target_os = "linux", windows))] +pub fn fetch_actions( + addr: &str, + mgmt_port: u16, + identity: &(String, String), + pin: Option<[u8; 32]>, +) -> Vec { + let Ok(agent) = crate::library::agent(identity, pin) else { + return Vec::new(); + }; + let url = format!( + "{}/api/v1/actions", + crate::library::base_url(addr, mgmt_port) + ); + let Ok(mut resp) = agent.get(&url).call() else { + return Vec::new(); + }; + let Ok(body) = resp.body_mut().read_to_string() else { + return Vec::new(); + }; + serde_json::from_str::(&body) + .map(|l| l.actions) + .unwrap_or_default() +} + +/// Invoke one action by id (`POST /api/v1/actions/{id}`, empty body). +/// +/// `Ok(())` means the host ACCEPTED it (202) — it now ends every session and acts about a second +/// later, so this is the last word the client will get on the subject. The error is already +/// user-facing: the host's own refusal sentence (another device is streaming, a foreign sleep +/// inhibitor, the platform said no), or the library lane's classified transport error. +#[cfg(any(target_os = "linux", windows))] +pub fn invoke( + addr: &str, + mgmt_port: u16, + identity: &(String, String), + pin: Option<[u8; 32]>, + action_id: &str, +) -> Result<(), crate::library::LibraryError> { + use crate::library::LibraryError; + let agent = crate::library::agent(identity, pin)?; + // The id is the whole request — the body stays empty (the host's rule: no request field ever + // reaches the privileged path). Percent-encoding is unnecessary and would be wrong: ids are + // `[a-z.]` by grammar, and anything else is an id this host will 404 anyway. + let url = format!( + "{}/api/v1/actions/{action_id}", + crate::library::base_url(addr, mgmt_port) + ); + match agent.post(&url).send_empty() { + Ok(_) => Ok(()), + // A refusal carries the host's reason in the `ApiError` envelope; surface THAT, because + // "409" tells a person nothing and "another device is streaming from this host right + // now" tells them exactly what to do. + Err(ureq::Error::StatusCode(code)) if (400..500).contains(&code) => Err( + LibraryError::Unreachable(format!("the host refused ({code})")), + ), + Err(e) => Err(crate::library::classify(e)), + } +} + +/// How long a host's answer stays fresh before [`refresh`] will ask again. Long on purpose: +/// what it governs — whether this device holds the Host-power grant, whether the box can +/// suspend — changes when an operator edits access, not minute to minute, and every refresh is +/// a TLS handshake against a host that is otherwise idle. +#[cfg(any(target_os = "linux", windows))] +pub const TTL: std::time::Duration = std::time::Duration::from_secs(300); + +/// The process-wide answer cache, by host fingerprint. +/// +/// Every desktop shell needs the same thing — a list settled BEFORE a menu draws (rows that +/// appear under a cursor already moving toward something else are a hazard when two of them +/// shut a machine down), refreshed rarely, shared across the screens that show it. One cache +/// with one TTL rule beats the same 40 lines in the console, the GTK page and the Windows +/// tile — which is how three shells end up disagreeing about what a host offers. +#[cfg(any(target_os = "linux", windows))] +type Cache = + std::sync::Mutex)>>; + +#[cfg(any(target_os = "linux", windows))] +fn cache() -> &'static Cache { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + C.get_or_init(Default::default) +} + +/// What this host last said it lets this device do — the OFFERABLE rows only, so a caller +/// renders what it gets. Empty until a [`refresh`] has answered, and empty for a host with no +/// such route or a device without the grant. +#[cfg(any(target_os = "linux", windows))] +pub fn cached(fp_hex: &str) -> Vec { + cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(fp_hex) + .map(|(_, a)| a.clone()) + .unwrap_or_default() +} + +/// Ask this host again, off the caller's thread, unless the cached answer is still inside +/// [`TTL`]. Cheap and idempotent: call it on whatever refresh tick a shell already has. +/// +/// The freshness stamp is taken BEFORE the request, so a slow or hanging host cannot make +/// every tick spawn another worker for it. The device identity is loaded on the worker rather +/// than taken as an argument — it is the same one file every shell already reads, and asking +/// for it here would mean threading it through three menus that have no other use for it. +#[cfg(any(target_os = "linux", windows))] +pub fn refresh(addr: &str, mgmt_port: u16, fp_hex: &str) { + if fp_hex.is_empty() { + return; // no pinned identity ⇒ nothing to authenticate as, and nothing to key on + } + { + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + match c.get_mut(fp_hex) { + Some(entry) if entry.0.elapsed() < TTL => return, + Some(entry) => entry.0 = std::time::Instant::now(), + None => { + c.insert(fp_hex.to_string(), (std::time::Instant::now(), Vec::new())); + } + } + } + let (addr, fp_hex) = (addr.to_string(), fp_hex.to_string()); + std::thread::Builder::new() + .name("punktfunk-hostactions".into()) + .spawn(move || { + let Ok(identity) = crate::trust::load_or_create_identity() else { + return; // no device identity ⇒ nothing to authenticate as + }; + let pin = crate::trust::parse_hex32(&fp_hex); + let found: Vec = fetch_actions(&addr, mgmt_port, &identity, pin) + .into_iter() + // The host decides who may see a row; a device without the grant is told + // nothing about the action beyond that it exists, and shows nothing. + .filter(ActionInfo::offerable) + .collect(); + cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fp_hex, (std::time::Instant::now(), found)); + }) + .ok(); +} + +/// Forget what this host said — call it right after invoking an action, because whatever it +/// said is about to be wrong. Without this, a menu goes on offering "Sleep host" on a machine +/// that is already asleep until the TTL lapses. +#[cfg(any(target_os = "linux", windows))] +pub fn invalidate(fp_hex: &str) { + cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(fp_hex); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Known ids wear this client's wording; an id from a future host wears the host's own + /// title, which is the whole no-client-release contract. + #[test] + fn labels_prefer_local_wording_and_fall_back_to_the_host() { + let mk = |id: &str, title: &str| ActionInfo { + id: id.into(), + title: title.into(), + group: "power".into(), + danger: false, + available: true, + unavailable_reason: None, + permitted: true, + }; + assert_eq!(mk("power.sleep", "Sleep host").label(), "Sleep host"); + assert_eq!(mk("power.reboot", "whatever").label(), "Restart host"); + assert_eq!( + mk("plugin:vpn:toggle", "Toggle the VPN").label(), + "Toggle the VPN" + ); + } + + /// Not-permitted hides the row (that is the device's access level, and it will not change + /// while the menu is open); unavailable KEEPS it, so the reason can be shown. + #[test] + fn permission_hides_but_unavailability_only_disables() { + let mut a = ActionInfo { + id: "power.sleep".into(), + title: "Sleep host".into(), + group: "power".into(), + danger: false, + available: false, + unavailable_reason: Some("this machine does not support sleep".into()), + permitted: true, + }; + assert!(a.offerable(), "unavailable actions are shown with a reason"); + a.permitted = false; + assert!(!a.offerable(), "an ungranted action is not offered at all"); + } +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index d0814abb4..a11680f58 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -63,6 +63,12 @@ pub mod library; // Per-host catalog cache, so a library screen has titles to show while a sleeping host boots. #[cfg(any(target_os = "linux", windows))] pub mod library_cache; +// Host actions — sleep/restart/shut down the host (design/host-actions.md §7). Android-enabled +// for the MODEL half (the row type + labelling rules the console screens read); the ureq calls +// inside stay desktop-gated, exactly like `library`, since Android dials the same routes through +// its own mTLS OkHttp client. +#[cfg(any(target_os = "linux", windows, target_os = "android"))] +pub mod host_actions; // Android-enabled for the RING half (note/render — std only): the client's "Send logs to // host" needs the ring on every platform. The `send_to_host` uploader inside stays // desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index a4925205d..aef1e49b2 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -64,7 +64,7 @@ pub use input::Key; pub use library::{LibraryGame, LibraryPhase, LibraryShared, Stale}; #[cfg(any(target_os = "linux", windows, target_os = "android"))] pub use model::{ - ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus, + ConsoleBus, ConsoleCmd, ConsoleShared, HostAction, HostRow, PairPhase, ProfileChip, WakeStatus, }; #[cfg(any(target_os = "linux", windows, target_os = "android"))] pub use platform::{Platform, PlatformScreen}; diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index 3516dc74e..d5242fcba 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -52,6 +52,13 @@ pub struct HostRow { /// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a /// follow-up — the Skia glyph set doesn't exist yet. pub os: String, + /// What this host lets THIS device do to it beyond streaming — sleep, restart, shut down + /// (`design/host-actions.md` §7), as the host itself reported them. Empty for an + /// unreachable host, an older one with no such route, and any device whose access does not + /// carry the Host-power grant: the host is the only judge of that, and a row it would + /// refuse is not offered. `serde(default)` so a producer predating the field still parses. + #[serde(default)] + pub actions: Vec, /// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right /// after its host's primary tile, sharing its live state, that connects with THIS /// profile. `None` = the host's primary tile. @@ -62,6 +69,30 @@ pub struct HostRow { pub bound_profile: Option, } +/// One action a host offers this device, resolved by the service thread from the host's own +/// `GET /api/v1/actions` (`design/host-actions.md` §3.2) — v1: sleep, restart, shut down. +/// +/// Presentational on purpose: the label is already the one this client would use for a known +/// id and the host's own title for an id this client has never heard of, so a later host can +/// add an action and this console renders it with no release. The shell never decides WHETHER +/// an action may run — the host answered that before the row existed, and answers it again on +/// invoke. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HostAction { + /// Stable action id, the invoke argument (`power.sleep`). + pub id: String, + /// Row label, already resolved (local wording for a known id, else the host's title). + pub label: String, + /// Confirm twice — the action loses state (restart, shut down). + pub danger: bool, + /// The host can run it right now. `false` still shows the row, disabled, because + /// "unavailable, because X" is more use than a row that quietly vanished. + pub available: bool, + /// Why it can't run, when it can't. Empty otherwise. + #[serde(default)] + pub unavailable_reason: String, +} + /// The pairing ceremony's observable state (one at a time — the ceremony is modal). #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] pub enum PairPhase { @@ -255,6 +286,23 @@ pub enum ConsoleCmd { /// of them is the same shape — do the platform thing, report back as a notice — and a /// command per grant would make adding the next pad a change in three crates. PadAction { action: String, pad_key: String }, + /// Invoke one of the host's own actions — sleep / restart / shut down it + /// (`design/host-actions.md`). Same lane and trust as [`ConsoleCmd::SendLogs`]; the host + /// re-checks this device's Host-power grant on arrival, so the row having existed grants + /// nothing. Parameterised by `action_id` for the same reason [`ConsoleCmd::PadAction`] is: + /// the host is free to grow the list, and a command per verb would make that a change in + /// three crates. The outcome arrives as a notice toast. + HostAction { + addr: String, + mgmt: u16, + fp_hex: String, + host_name: String, + /// The action's stable id (`power.sleep`). + action_id: String, + /// Its resolved label, for the toast — so the service thread need not re-derive + /// wording the screen has already settled. + label: String, + }, } /// The overlay→binary command queue. A plain deque under the same locking discipline as @@ -297,6 +345,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, }; diff --git a/crates/pf-console-ui/src/screens/bind_profile.rs b/crates/pf-console-ui/src/screens/bind_profile.rs index fe9d903dd..5445414ec 100644 --- a/crates/pf-console-ui/src/screens/bind_profile.rs +++ b/crates/pf-console-ui/src/screens/bind_profile.rs @@ -224,6 +224,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: bound.map(|id| ProfileChip { id: id.into(), diff --git a/crates/pf-console-ui/src/screens/collections.rs b/crates/pf-console-ui/src/screens/collections.rs index e74019551..934df8c5f 100644 --- a/crates/pf-console-ui/src/screens/collections.rs +++ b/crates/pf-console-ui/src/screens/collections.rs @@ -827,6 +827,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, } diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 4fb7b0181..2d9f7981c 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -832,6 +832,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, } diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 65f9d1d10..966fd517d 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -2167,6 +2167,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, } diff --git a/crates/pf-console-ui/src/screens/options.rs b/crates/pf-console-ui/src/screens/options.rs index 14cfdef9a..b71e6ff0e 100644 --- a/crates/pf-console-ui/src/screens/options.rs +++ b/crates/pf-console-ui/src/screens/options.rs @@ -33,6 +33,12 @@ use skia_safe::{Canvas, Rect}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Action { Wake, + /// One of the host's OWN actions — sleep / restart / shut down it + /// (`design/host-actions.md` §7), indexed into [`HostRow::actions`], which the service + /// thread filled from the host's discovery. Indexed rather than a variant per verb + /// because the console must render an action this build has never heard of: the host + /// already sent a label and said whether this device may run it. + Host(usize), SendLogs, /// Open this host's game library — the same shelf the home carousel's Y opens, offered /// here because Y is a face button and a TV remote has none. Saved-and-paired only, @@ -76,11 +82,15 @@ pub(crate) struct OptionsScreen { /// be able to do that. subject: Subject, list: MenuList, - /// Forget is the one action here with no undo, so the row arms on the first press and - /// only fires on the second. The other clients forget outright; a console is driven by - /// a thumbstick from across a room, which is a good reason to be stricter than they + /// The row currently armed, if any: an action with no undo arms on the first press and + /// only fires on the second. Forget was the first; the host's own destructive actions + /// (restart, shut down) join it. The other clients forget outright; a console is driven + /// by a thumbstick from across a room, which is a good reason to be stricter than they /// are, and none at all to be looser. - armed: bool, + /// + /// Holding WHICH action is armed, rather than a bare flag, is what stops an arming press + /// on one destructive row from firing a different one the cursor then landed on. + armed: Option, } impl OptionsScreen { @@ -92,7 +102,7 @@ impl OptionsScreen { OptionsScreen { subject, list: MenuList::new(), - armed: false, + armed: None, } } @@ -148,6 +158,11 @@ impl OptionsScreen { if host.can_wake && !host.online { a.push(Action::Wake); } + // …and the other half of that round trip, immediately below it: the host's own + // actions, as IT reported them for this device. Nothing is decided here — the list is + // empty unless the host is reachable and this device's access carries the grant, so + // "Sleep host" appears exactly where "Wake host" was the evening before. + a.extend((0..host.actions.len()).map(Action::Host)); // "Send logs" needs a paired identity (the upload authenticates with the streaming // cert) and a reachable host — on anything else the row would only ever toast an // error. This is the log-escape hatch for platforms whose own filesystem the user @@ -180,6 +195,13 @@ impl OptionsScreen { fn label(&self, a: Action) -> String { match a { Action::Wake => "Wake host".into(), + Action::Host(i) => match self.host().actions.get(i) { + Some(act) if self.armed == Some(a) => { + format!("{} \u{2014} press again", act.label) + } + Some(act) => act.label.clone(), + None => String::new(), + }, Action::SendLogs => "Send logs to host".into(), Action::Library => "Library".into(), Action::CopyLink => "Copy link".into(), @@ -193,13 +215,27 @@ impl OptionsScreen { "Off" } ), - Action::Forget if self.armed => "Forget \u{2014} press again".into(), + Action::Forget if self.armed == Some(Action::Forget) => { + "Forget \u{2014} press again".into() + } Action::Forget => "Forget".into(), Action::Unpin => "Unpin card".into(), Action::Cancel => "Cancel".into(), } } + /// Whether a row reads as live. Only a host action can be dead: the host said it cannot + /// run that verb right now (no suspend support, a foreign inhibitor, a second local user). + /// The row stays — activating it explains why — because a row that quietly vanished would + /// leave the person wondering whether they had imagined it (the host's own honesty rule: + /// "unavailable, because X", never a dead switch and never a silence). + fn enabled(&self, a: Action) -> bool { + match a { + Action::Host(i) => self.host().actions.get(i).is_none_or(|act| act.available), + _ => true, + } + } + pub(crate) fn menu( &mut self, ev: MenuEvent, @@ -236,10 +272,11 @@ impl OptionsScreen { let Some(action) = actions.get(self.list.cursor).copied() else { return pulse; }; - // Moving off the armed Forget row disarms it: an arming press is about THAT row, - // and leaving it must not leave a live trigger behind for the next visit. - if !matches!(msg, ListMsg::Activate) && action != Action::Forget { - self.armed = false; + // Moving off an armed row disarms it: an arming press is about THAT row, and leaving + // it must not leave a live trigger behind — neither for the next visit, nor for the + // destructive row the cursor happened to land on next. + if !matches!(msg, ListMsg::Activate) && self.armed != Some(action) { + self.armed = None; } match msg { ListMsg::Adjust(_) => Some(MenuPulse::Boundary), @@ -340,12 +377,52 @@ impl OptionsScreen { fx.cmds.push(ConsoleCmd::SetClipboard { key, on }); fx.pop(); } - Action::Forget if !self.armed => self.armed = true, + Action::Forget if self.armed != Some(Action::Forget) => { + self.armed = Some(Action::Forget) + } Action::Forget => { fx.cmds.push(ConsoleCmd::ForgetHost { key }); fx.toast = Some(format!("Forgot {}", self.host().name)); fx.pop(); } + Action::Host(i) => { + let host = self.host(); + let Some(act) = host.actions.get(i) else { + return; // the row list changed under the cursor — do nothing, silently + }; + // A host the host itself says it cannot do right now: say why rather than + // send a request we know it will refuse. + if !act.available { + let why = act.unavailable_reason.clone(); + fx.toast = Some(if why.is_empty() { + format!("{} isn't available right now", act.label) + } else { + why + }); + fx.pop(); + return; + } + // Restart and shut down lose whatever is running on that machine, so they take + // the Forget treatment: arm, then fire. Sleep is reversible from the same menu + // (Wake host), so it goes on one press. + if act.danger && self.armed != Some(action) { + self.armed = Some(action); + return; + } + fx.cmds.push(ConsoleCmd::HostAction { + addr: host.addr.clone(), + mgmt: host.mgmt_port, + fp_hex: host.fp_hex.clone(), + host_name: host.name.clone(), + action_id: act.id.clone(), + label: act.label.clone(), + }); + fx.toast = Some(format!( + "{} \u{2014} asking {}\u{2026}", + act.label, host.name + )); + fx.pop(); + } Action::Unpin => { if let Some(p) = &self.host().pin { fx.cmds.push(ConsoleCmd::SetPin { @@ -412,7 +489,7 @@ impl OptionsScreen { let rows: Vec = self .actions(ctx.platform) .into_iter() - .map(|a| RowSpec::action(self.label(a), true)) + .map(|a| RowSpec::action(self.label(a), self.enabled(a))) .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); @@ -473,11 +550,35 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, } } + /// A host that reported the three power actions for this device, sleep available. + fn powered() -> HostRow { + let act = |id: &str, label: &str, danger: bool, available: bool| crate::model::HostAction { + id: id.into(), + label: label.into(), + danger, + available, + unavailable_reason: if available { + String::new() + } else { + "this machine does not support sleep".into() + }, + }; + HostRow { + actions: vec![ + act("power.sleep", "Sleep host", false, true), + act("power.reboot", "Restart host", true, true), + act("power.shutdown", "Shut down host", true, true), + ], + ..host() + } + } + fn pinned() -> HostRow { HostRow { key: "aa\u{0}prof-1".into(), @@ -550,6 +651,102 @@ mod tests { .contains(&Action::SendLogs)); } + /// The host's own actions sit right under Wake — the two halves of one round trip — and + /// only exist because the HOST offered them: an empty list (older host, unreachable host, + /// or a device without the grant) leaves the menu exactly as it was. + #[test] + fn host_actions_appear_only_when_the_host_offered_them() { + let none = OptionsScreen::for_host(&host()); + assert!(!none + .actions(crate::platform::Platform::Desktop) + .iter() + .any(|a| matches!(a, Action::Host(_)))); + + let s = OptionsScreen::for_host(&powered()); + let rows = s.actions(crate::platform::Platform::Desktop); + assert_eq!( + rows.iter().filter(|a| matches!(a, Action::Host(_))).count(), + 3 + ); + assert_eq!(s.label(Action::Host(0)), "Sleep host"); + // An id this build has never heard of still renders — the host sent the label. + let future = OptionsScreen::for_host(&HostRow { + actions: vec![crate::model::HostAction { + id: "plugin:vpn:toggle".into(), + label: "Toggle the VPN".into(), + danger: false, + available: true, + unavailable_reason: String::new(), + }], + ..host() + }); + assert_eq!(future.label(Action::Host(0)), "Toggle the VPN"); + } + + /// Sleep is reversible from this very menu, so it goes on one press. Restart and shut down + /// are not, so they take Forget's arm-then-fire — and arming one must never leave the + /// OTHER one live, which is exactly the bug a bare `armed` flag would have shipped. + #[test] + fn destructive_host_actions_arm_before_they_fire() { + let mut s = OptionsScreen::for_host(&powered()); + let mut fx = Outbox::default(); + run_action(&mut s, Action::Host(0), &mut fx); // sleep — one press + assert!(matches!( + fx.cmds.first(), + Some(ConsoleCmd::HostAction { action_id, .. }) if action_id == "power.sleep" + )); + + let mut s = OptionsScreen::for_host(&powered()); + let mut fx = Outbox::default(); + run_action(&mut s, Action::Host(2), &mut fx); // shut down — arms + assert!(fx.cmds.is_empty(), "the first press only arms"); + assert_eq!( + s.label(Action::Host(2)), + "Shut down host \u{2014} press again" + ); + // The armed row is that one row: moving to Restart and pressing must not shut down. + assert_eq!(s.label(Action::Host(1)), "Restart host"); + let mut fx = Outbox::default(); + run_action(&mut s, Action::Host(1), &mut fx); + assert!( + fx.cmds.is_empty(), + "arming shut down must not leave restart armed" + ); + // Pressing the armed row again fires it. + let mut s = OptionsScreen::for_host(&powered()); + let mut fx = Outbox::default(); + run_action(&mut s, Action::Host(2), &mut fx); + run_action(&mut s, Action::Host(2), &mut fx); + assert!(matches!( + fx.cmds.first(), + Some(ConsoleCmd::HostAction { action_id, .. }) if action_id == "power.shutdown" + )); + } + + /// An action the host says it cannot run right now stays on the menu, disabled, and + /// explains itself — never a silent row and never a request we know will be refused. + #[test] + fn an_unavailable_action_explains_itself_instead_of_firing() { + let mut s = OptionsScreen::for_host(&HostRow { + actions: vec![crate::model::HostAction { + id: "power.sleep".into(), + label: "Sleep host".into(), + danger: false, + available: false, + unavailable_reason: "this machine does not support sleep".into(), + }], + ..host() + }); + assert!(!s.enabled(Action::Host(0))); + let mut fx = Outbox::default(); + run_action(&mut s, Action::Host(0), &mut fx); + assert!(fx.cmds.is_empty(), "no request the host would refuse"); + assert_eq!( + fx.toast.as_deref(), + Some("this machine does not support sleep") + ); + } + #[test] fn a_pinned_card_cannot_forget_or_edit_the_host() { let s = OptionsScreen::for_host(&pinned()); @@ -654,7 +851,7 @@ mod tests { run_action(&mut s, Action::Forget, &mut fx); assert!(fx.cmds.is_empty(), "the first press only arms"); - assert!(s.armed); + assert_eq!(s.armed, Some(Action::Forget)); assert!(s.label(Action::Forget).contains("press again")); run_action(&mut s, Action::Forget, &mut fx); @@ -669,7 +866,7 @@ mod tests { fn leaving_the_forget_row_disarms_it() { let mut s = OptionsScreen::for_host(&host()); let actions = s.actions(crate::platform::Platform::Desktop); - s.armed = true; + s.armed = Some(Action::Forget); s.list.cursor = actions.iter().position(|a| *a == Action::Cancel).unwrap(); let mut ctx_settings = pf_client_core::trust::Settings::default(); let mut ctx = Ctx { @@ -686,7 +883,10 @@ mod tests { }; let mut fx = Outbox::default(); s.dispatch(ListMsg::None, None, &actions, &mut ctx, &mut fx); - assert!(!s.armed, "a cursor move off the row cancels the arming"); + assert_eq!( + s.armed, None, + "a cursor move off the row cancels the arming" + ); } #[test] diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index d176f70de..044eca0e7 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -479,6 +479,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, } diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs index e1dfc355c..355c6c0e9 100644 --- a/crates/pf-console-ui/src/screens/pin_hosts.rs +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -210,6 +210,7 @@ mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: pin.map(|id| ProfileChip { id: id.into(), name: "Work".into(), diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 9717b7a10..5a81666af 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -2523,6 +2523,7 @@ pub(super) mod tests { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: Some(crate::model::ProfileChip { id: "p1".into(), name: "Work".into(), diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index de34ffdc6..b29d55d0f 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -92,6 +92,7 @@ fn hosts() -> Vec { clipboard_sync: false, last_used: None, os: String::new(), + actions: Vec::new(), pin: None, bound_profile: None, }; diff --git a/docs-site/content/docs/host-power.md b/docs-site/content/docs/host-power.md index 097975480..edca3bca1 100644 --- a/docs-site/content/docs/host-power.md +++ b/docs-site/content/docs/host-power.md @@ -8,6 +8,11 @@ loop: **Sleep host**, **Restart host** and **Shut down host**, from the web cons (password-confirmed) or — with the right access — from a paired client. Finish playing on the TV, sleep the host from the couch, wake it again tomorrow. +On a client the rows sit in the host's own menu, right where **Wake host** appears when it is +asleep: the gamepad console's host options, the Linux and Windows host card menus, and the Apple +and Android host cards. They appear only when the host offered them, so a device without the +grant simply has no power rows. Restart and shut down confirm before they run. + ## Who may do it - The **web console** always can — it is the operator's own surface, behind the console login -- 2.54.0 From 40fc741ad58cb2cc1eedd1e46277d6490ec32617 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 28 Aug 2026 23:50:44 +0200 Subject: [PATCH 3/3] chore(ci): the three new ABI constants join the undocumented-env baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-drift ratchet matches PUNKTFUNK_* by token spelling, not by env read, so the cbindgen #defines this branch adds — GRANT_POWER, GRANT_ALL_PRE_POWER and HOST_POWER_CLOSE_CODE — read as new undocumented knobs. They are ABI constants, not knobs: every sibling grant bit and close code is already in this file for the same reason. --- scripts/ci/docs-undocumented-env-baseline.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/ci/docs-undocumented-env-baseline.txt b/scripts/ci/docs-undocumented-env-baseline.txt index 57c7eadce..98e9669e7 100644 --- a/scripts/ci/docs-undocumented-env-baseline.txt +++ b/scripts/ci/docs-undocumented-env-baseline.txt @@ -116,12 +116,14 @@ PUNKTFUNK_GAMESCOPE_WSI PUNKTFUNK_GAMESCOPE_WSI_DISABLE PUNKTFUNK_GATE_DEPTH PUNKTFUNK_GRANT_ALL +PUNKTFUNK_GRANT_ALL_PRE_POWER PUNKTFUNK_GRANT_CLIPBOARD PUNKTFUNK_GRANT_GAMEPAD PUNKTFUNK_GRANT_KEYBOARD PUNKTFUNK_GRANT_LAUNCH PUNKTFUNK_GRANT_MIC PUNKTFUNK_GRANT_POINTER +PUNKTFUNK_GRANT_POWER PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY PUNKTFUNK_GRANT_PRESET_FULL PUNKTFUNK_GRANT_PRESET_VIEW_ONLY @@ -145,6 +147,7 @@ PUNKTFUNK_HOST_CAP_GAMEPAD_STATE PUNKTFUNK_HOST_CAP2_REPEAT_MARK PUNKTFUNK_HOST_CAP_PEN PUNKTFUNK_HOST_CAP_TEXT_INPUT +PUNKTFUNK_HOST_POWER_CLOSE_CODE PUNKTFUNK_HOST_TIMING_MAGIC PUNKTFUNK_HW_FAULT PUNKTFUNK_INBOUND_REQ_FLAG -- 2.54.0