diff --git a/api/openapi.json b/api/openapi.json index 13ed549f..867ab315 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.22.3" + "version": "0.23.0" }, "paths": { "/api/v1/clients": { @@ -2170,6 +2170,51 @@ } } }, + "/api/v1/plugins/logs": { + "post": { + "tags": [ + "plugins" + ], + "summary": "Ingest runner log lines", + "description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll.", + "operationId": "ingestPluginLogs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginLogBatch" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Lines ingested" + }, + "400": { + "description": "Batch too large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/plugins/{id}": { "put": { "tags": [ @@ -6238,6 +6283,50 @@ "gamestream" ] }, + "PluginLogBatch": { + "type": "object", + "description": "A batch of runner log lines.", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginLogLine" + } + } + } + }, + "PluginLogLine": { + "type": "object", + "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).", + "required": [ + "ts_ms", + "level", + "source", + "msg" + ], + "properties": { + "level": { + "type": "string", + "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." + }, + "msg": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." + }, + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", + "minimum": 0 + } + } + }, "PluginRegistration": { "type": "object", "description": "Register/renew body for `PUT /plugins/{id}`.", diff --git a/clients/decky/README.md b/clients/decky/README.md index cb6aa0b4..20778036 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -24,8 +24,15 @@ the panel looks and feels native to Gaming Mode. browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive plugin reinstalls (stored next to the client's config) and follow a host across IP changes (matched by certificate fingerprint). -5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written - to the client's config. +5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's + own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs + scrolling. The categories and their order are the console settings screen's — Stream (resolution + / refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4), + Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic + device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake / + library / fullscreen). The device pickers are populated + from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where + there is more than one adapter. 6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and a force-stop for a wedged stream client. @@ -93,7 +100,7 @@ restart is required for an out-of-band install to appear. | --- | --- | | `src/index.tsx` | Plugin entry: the QAM panel + route registration. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | -| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. | +| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. | | `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. | | `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. | | `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). | diff --git a/clients/decky/main.py b/clients/decky/main.py index fa0e8a43..19e73116 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do: the frontend so it can create/point the Steam shortcut. * **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. + ``set_settings`` MERGES onto the file: it is shared with the desktop client and the console. +* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's + device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``) + and cached, since enumerating them costs a Vulkan + PipeWire init. * **kill_stream()** — force-stop a wedged stream (``flatpak kill``). * **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own @@ -343,6 +347,9 @@ def _flatpak() -> str | None: # settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real # home), so nothing else in this file has to care which one answered. NATIVE_BIN = "punktfunk-client" +# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate +# this device's GPUs and audio endpoints for the settings pickers. +SESSION_BIN = "punktfunk-session" # Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and # SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix. @@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None: return [native] if native else None +def _session_argv() -> list[str] | None: + """The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there. + + The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on + `punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and + shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The + flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native + install puts them in the same bindir, so the session is the client's sibling. + """ + prefix = _client_argv() + if not prefix: + return None + if prefix[0] == _flatpak(): + # `flatpak run --command= ` — the app id must stay LAST. + return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]] + sibling = Path(prefix[0]).with_name(SESSION_BIN) + return [str(sibling)] if sibling.exists() else None + + def _client_is_flatpak() -> bool: """Is the client this plugin actually drives the FLATPAK one? @@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in return -1, "", "" +def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]: + """Split `punktfunk-session --list-audio` into ``(sinks, sources)``. + + Its format is one endpoint per line, ``sink|sourcenode.namedescription``. The + node.name is what gets STORED (it is the stable id the client resolves against), so a line + without one is unusable and dropped; a missing description falls back to the name rather than + rendering a picker entry with no label. Anything else on the line is ignored, so an extra + trailing column in a future client can't break this. + """ + sinks: list[dict] = [] + sources: list[dict] = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 3 or not parts[1].strip(): + continue + kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip() + entry = {"name": name, "description": description or name} + if kind == "sink": + sinks.append(entry) + elif kind == "source": + sources.append(entry) + return sinks, sources + + +async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]: + """Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when + it isn't installed or the call errors/times out. + + Only ever used for the two read-only device enumerations — the launch path goes through the + Steam shortcut and the wrapper script, never through here. The timeout is generous because + `--list-adapters` initialises Vulkan on a cold flatpak.""" + prefix = _session_argv() + if not prefix: + return -1, "" + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *prefix, *session_args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + env=_flatpak_env(), + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + rc = proc.returncode if proc.returncode is not None else -1 + return rc, (out or b"").decode("utf-8", "replace") + except asyncio.TimeoutError: + decky.logger.warning("session %s timed out", " ".join(session_args)) + if proc: + try: + proc.kill() + except ProcessLookupError: + pass + return -1, "" + except Exception: # noqa: BLE001 + decky.logger.exception("session %s failed", " ".join(session_args)) + return -1, "" + + # The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the # QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability # probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any @@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in _HOSTS_TTL_S = 12.0 _hosts_cache: dict = {"at": 0.0, "probed": None, "data": None} +# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading +# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices` +# clears it for the user who just plugged a headset in. +_devices_cache: dict = {"data": None} + def _invalidate_hosts_cache() -> None: _hosts_cache["data"] = None @@ -1044,25 +1132,84 @@ class Plugin: try: return json.loads(_settings_path().read_text()) except (OSError, json.JSONDecodeError): - # The client's own defaults (native display, host-default bitrate, auto pad). + # The client's own defaults (native display, host-default bitrate, auto pad, + # stats overlay at Normal — `Settings::default` is `show_stats: true`). return { "width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0, "bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "gamepad_forwarding": True, "compositor": "auto", "inhibit_shortcuts": True, "mic_enabled": False, + "stats_verbosity": "normal", "show_stats": True, } async def set_settings(self, settings: dict) -> dict: - """Write the stream settings JSON the (sandboxed) client reads on launch.""" + """Write the stream settings JSON the (sandboxed) client reads on launch. + + MERGED onto whatever is on disk, never a wholesale replace: this file is shared with + the desktop client and the console's settings screen, and it holds far more keys than + this panel models (decoder, GPU, profiles, touch/mouse model…). The panel reads it once + when it mounts, so a straight write would post a snapshot that predates anything those + other editors stored in the meantime — silently reverting it. + """ try: d = _client_config_dir() d.mkdir(parents=True, exist_ok=True) - _settings_path().write_text(json.dumps(settings, indent=2)) + try: + on_disk = json.loads(_settings_path().read_text()) + if not isinstance(on_disk, dict): + on_disk = {} + except (OSError, json.JSONDecodeError): + on_disk = {} # no file yet (or an unreadable one): this write creates it + on_disk.update(settings) + _settings_path().write_text(json.dumps(on_disk, indent=2)) return {"ok": True} except OSError as exc: decky.logger.exception("could not write settings") return {"ok": False, "error": str(exc)} + async def list_devices(self) -> dict: + """GPUs + audio endpoints for the settings tab's device pickers. + + Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the + Decky session: hardware doesn't come and go often enough to justify paying that on every + remount of the page, and a stale entry is harmless — a picked device that has since + vanished falls back to the OS default in the client anyway. `refresh_devices` clears it. + + Best-effort in the same way every other client call here is: no session binary (an old + flatpak that predates the two-binary split, or a native install missing its sibling) just + means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as + "you have no devices". + """ + if _devices_cache["data"] is not None: + return _devices_cache["data"] + + adapters: list[str] = [] + sinks: list[dict] = [] + sources: list[dict] = [] + rc_a, out_a = await _run_session(["--list-adapters"]) + if rc_a == 0: + adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()] + rc_d, out_d = await _run_session(["--list-audio"]) + if rc_d == 0: + sinks, sources = _parse_audio_endpoints(out_d) + + result = { + "ok": rc_a == 0 or rc_d == 0, + "adapters": adapters, + "sinks": sinks, + "sources": sources, + } + # Only a run that actually answered is worth remembering — caching a failure would make + # a client installed after the page was first opened stay invisible until a Decky restart. + if result["ok"]: + _devices_cache["data"] = result + return result + + async def refresh_devices(self) -> dict: + """Drop the cached enumeration and read it again (a headset was just plugged in).""" + _devices_cache["data"] = None + return await self.list_devices() + # ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ---- async def list_hosts(self, probe: bool = True) -> dict: diff --git a/clients/decky/scripts/test-backend.py b/clients/decky/scripts/test-backend.py index f1f20cc0..5e335262 100644 --- a/clients/decky/scripts/test-backend.py +++ b/clients/decky/scripts/test-backend.py @@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"] check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True) shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True) +# ---- `--list-audio` parsing (the settings tab's device pickers) -------------------------- +sinks, sources = main._parse_audio_endpoints( + "sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n" + "sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n" + "source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n" +) +check("audio: sinks parsed", [d["name"] for d in sinks] == [ + "alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1" +]) +check("audio: sources parsed", len(sources) == 1) +check("audio: description kept", sinks[1]["description"] == "WH-1000XM4") + +# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a +# short line is malformed, and an unknown kind belongs to neither list. A blank description +# falls back to the name so no entry renders unlabelled. +sinks, sources = main._parse_audio_endpoints( + "sink\t\tNo node name\n" + "sink\tonly-two-columns\n" + "monitor\tsome.monitor\tNot a sink or source\n" + "source\tbare.node\t\n" + "\n" +) +check("audio: junk lines dropped", sinks == []) +check("audio: blank description falls back to the node name", sources == [ + {"name": "bare.node", "description": "bare.node"} +]) + print() if failures: print(f"{failures} check(s) FAILED") diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index e464e2d7..d7d21f24 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -101,27 +101,97 @@ export interface RunnerInfo { client_bin?: string; } -// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more -// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched -// because get_settings returns the whole parsed file and patches are object spreads. +// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client +// and the console's settings screen own, so a value changed in any of them shows in the others. +// +// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that +// cannot be answered from a plugin backend or aren't settings at all: +// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only +// the client process has; there is no CLI that enumerates pads. +// • `last_window_w/h` — the session's remembered window size, written BY the client, not a +// preference anyone sets. +// Both round-trip untouched: get_settings returns the whole parsed file, patches are object +// spreads, and set_settings merges onto what's on disk. +// +// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before +// that key existed simply lacks it. Read those through the same fallback the client uses — +// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here +// while the stream runs with it on. export interface StreamSettings { + // ---- Stream mode ---- width: number; // 0 = native height: number; // 0 = native refresh_hz: number; // 0 = native render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files) bitrate_kbps: number; // 0 = host default - codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files) + compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" + // Stream mode follows the session window instead of width/height, renegotiating on resize. + // Overrides width/height while on; degenerates to the display's native mode on fullscreen. + match_window?: boolean; + + // ---- Video ---- + codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files) + decoder?: string; // "auto" | "vulkan" | "vaapi" | "software" + hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10 + enable_444?: boolean; // default off — ask for full chroma + adapter?: string; // decode/present GPU by marketing name; "" = automatic + + // ---- Presentation ---- + // What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared + // with the Apple and Android clients under this name, so one profile reads the same everywhere. + present_priority?: string; + smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3 + vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort) + allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream + + // ---- Audio ---- + audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1) + speaker_device?: string; // PipeWire node.name for playback; "" = system default + mic_enabled: boolean; + mic_device?: string; // PipeWire node.name for capture; "" = system default + echo_cancel?: boolean; // default ON; only meaningful while mic_enabled + + // ---- Controllers ---- gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" // Forward this device's controllers at all. Absent in pre-forwarding files, where the // client's own serde default (true) applies — so `?? true` at every read, never `!!`. gamepad_forwarding?: boolean; - compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" - // Round-trips only — deliberately NOT offered as a row here. It decides whether the session - // grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no - // compositor shortcuts to inhibit and hands the focused window every key already. A toggle - // here would be a dead one. The desktop client's row still edits this same file. + + // ---- Touchscreen, mouse & keyboard ---- + touch_mode?: string; // "trackpad" | "pointer" | "touch" + mouse_mode?: string; // "capture" | "desktop" + invert_scroll?: boolean; + // Whether the session grabs the keyboard so Alt+Tab/Super reach the host. inhibit_shortcuts: boolean; - mic_enabled: boolean; + + // ---- Interface & behaviour ---- + // Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file, + // which resolves through `show_stats` — read both the way the client's + // `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does. + stats_verbosity?: string; + // The legacy on/off the tier supersedes; kept written in sync so a client that predates the + // tiers still honours an Off chosen here. + show_stats?: boolean; + fullscreen_on_stream?: boolean; + auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting + library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own) +} + +// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the +// human name to show. +export interface AudioDevice { + name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store + description: string; // human label ("Steam Deck Speakers") +} + +// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`). +// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the +// pickers stay on their stored value rather than pretending the device is gone. +export interface DeviceLists { + ok: boolean; + adapters: string[]; // Vulkan physical devices, discrete first + sinks: AudioDevice[]; // playback endpoints + sources: AudioDevice[]; // capture endpoints } export interface UpdateInfo { @@ -188,6 +258,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings"); export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( "set_settings", ); +// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and +// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path. +export const listDevices = callable<[], DeviceLists>("list_devices"); +// The same, bypassing the backend's cache — for the user who just plugged in a headset. +export const refreshDevices = callable<[], DeviceLists>("refresh_devices"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); // Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is // up by the time the stream connects. The MAC is looked up from the flatpak client's own diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index cae0640f..8b334bc8 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -334,8 +334,14 @@ const HostsTab: FC<{ ); +// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail + +// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an +// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and +// keeps its hands off the overflow. The footer inset lives inside the pages instead. +const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" }; + const SettingsTab: FC = () => ( -
+
); diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx index 18edd919..f6b84e83 100644 --- a/clients/decky/src/settings.tsx +++ b/clients/decky/src/settings.tsx @@ -1,10 +1,59 @@ -// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to -// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The -// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. -import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; -import { CSSProperties, FC, useEffect, useState } from "react"; -import { getSettings, setSettings, StreamSettings } from "./backend"; -import { RowActions } from "./ui"; +// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on +// launch (main.py set_settings, merged onto what's on disk). This is the same +// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value +// changed in any of the three shows in the other two. +// +// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split +// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings +// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is +// the whole point of the split: the rail is the index, so nothing is more than one hop away. +// +// The categories, their order, and the wording of the rows are the console's settings screen +// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user +// reaches without leaving Gaming Mode, and two different orders for one store is how people stop +// trusting either. It shows them as one steppable list because it has no pointer and no room for +// a rail; here they become the rail's pages, same groups, same sequence. Three more rules: +// +// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the +// console dims those rows rather than dropping them, and a row that vanishes as you toggle +// the one above it is a moving target for a thumbstick. +// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a +// one-GPU Deck). A dead control is worse than an absent one. +// • Anything that behaves differently *here* than it does on a desktop says so in its own +// description, rather than being silently dropped from the screen. +// +// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name` +// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` / +// `MouseMode` enums, which serialize lowercase. +import { + DialogButton, + Dropdown, + Field, + SidebarNavigation, + SliderField, + Spinner, + ToggleField, +} from "@decky/ui"; +import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react"; +import { + FaDesktop, + FaGamepad, + FaHandPointer, + FaSlidersH, + FaTv, + FaVideo, + FaVolumeUp, +} from "react-icons/fa"; +import { + AudioDevice, + DeviceLists, + getSettings, + listDevices, + refreshDevices, + setSettings, + StreamSettings, +} from "./backend"; +import { actionButton, RowActions } from "./ui"; // Decky's Dropdown has no width prop — it fills whatever container it's in, and a // `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell @@ -17,50 +66,543 @@ const selectShell: CSSProperties = { maxWidth: "24em", }; +// ---------------------------------------------------------------------------------------- +// Option tables — the console's, so the two Gaming-Mode editors offer the same choices. +// ---------------------------------------------------------------------------------------- + +// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on. +// Match window is offered even though this plugin's launches are always fullscreen (where it +// degenerates to the display's native mode) — leaving it out would make the row lie about a +// store the desktop client can set it in. +const MATCH_WINDOW = "match"; const RESOLUTIONS: [number, number, string][] = [ [0, 0, "Native display"], [1280, 720, "1280 × 720"], [1280, 800, "1280 × 800 (Deck)"], [1920, 1080, "1920 × 1080"], [2560, 1440, "2560 × 1440"], + [3840, 2160, "3840 × 2160"], ]; +const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`); + const REFRESH = [0, 30, 60, 90, 120]; // Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native. const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; const renderScaleLabel = (x: number): string => x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`; -const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"]; -const GAMEPAD_LABELS: Record = { - auto: "Automatic", - xbox360: "Xbox 360", - xboxone: "Xbox One", - dualsense: "DualSense", - dualshock4: "DualShock 4", - steamdeck: "Steam Deck", + +const COMPOSITORS: [string, string][] = [ + ["auto", "Automatic"], + ["kwin", "KDE Plasma (KWin)"], + ["wlroots", "Sway (wlroots)"], + ["mutter", "GNOME (Mutter)"], + ["gamescope", "gamescope"], +]; +const CODECS: [string, string][] = [ + ["auto", "Automatic"], + ["hevc", "HEVC (H.265)"], + ["h264", "H.264 (AVC)"], + ["av1", "AV1"], + // Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected + // when the host advertises it too; anything else falls back to HEVC. + ["pyrowave", "PyroWave (wired LAN)"], +]; +const DECODERS: [string, string][] = [ + ["auto", "Automatic"], + ["vulkan", "Vulkan Video"], + ["vaapi", "VAAPI"], + ["software", "Software"], +]; +// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so +// one profile reads the same on every device. +const PRESENT_PRIORITIES: [string, string][] = [ + ["latency", "Lowest latency"], + ["smooth", "Smoothness"], +]; +// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2). +const SMOOTH_BUFFERS: [number, string][] = [ + [0, "Automatic"], + [1, "1 frame"], + [2, "2 frames"], + [3, "3 frames"], +]; +const AUDIO_CHANNELS: [number, string][] = [ + [2, "Stereo"], + [6, "5.1 surround"], + [8, "7.1 surround"], +]; +const GAMEPADS: [string, string][] = [ + ["auto", "Automatic"], + ["xbox360", "Xbox 360"], + ["xboxone", "Xbox One"], + ["dualsense", "DualSense"], + ["dualshock4", "DualShock 4"], + ["steamdeck", "Steam Deck"], +]; +const TOUCH_MODES: [string, string][] = [ + ["trackpad", "Trackpad"], + ["pointer", "Direct pointer"], + ["touch", "Touch passthrough"], +]; +const MOUSE_MODES: [string, string][] = [ + ["capture", "Capture (games)"], + ["desktop", "Desktop (absolute)"], +]; +const STATS_TIERS: [string, string][] = [ + ["off", "Off"], + ["compact", "Compact"], + ["normal", "Normal"], + ["detailed", "Detailed"], +]; + +// ---------------------------------------------------------------------------------------- +// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the +// twelve of them below stay one line each and can't drift apart. +// ---------------------------------------------------------------------------------------- + +const SelectRow = ({ + label, + description, + options, + value, + onChange, + formatUnknown, + disabled, + indent, +}: { + label: string; + description?: ReactNode; + options: [T, string][]; + value: T; + onChange: (v: T) => void; + // How to name a stored value this table doesn't list (see below); defaults to the raw value. + formatUnknown?: (v: T) => string; + disabled?: boolean; + indent?: boolean; +}): ReactElement => { + // A Dropdown can only display a value that is one of its options, and this store has four other + // writers — the desktop client, the console, a settings profile, a newer client with presets + // this build doesn't know. Rather than render a blank control (or, worse, silently show a + // different value than the stream will actually use), carry the stored one as its own entry. + const shown: [T, string][] = options.some(([v]) => v === value) + ? options + : [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]]; + return ( + + +
+ ({ data, label: l }))} + selectedOption={value} + onChange={(o) => onChange(o.data as T)} + /> +
+
+
+ ); }; -// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host -// falls back from when its GPU can't encode it. -const CODECS = ["auto", "hevc", "h264", "av1"]; -const CODEC_LABELS: Record = { - auto: "Automatic", - hevc: "HEVC (H.265)", - h264: "H.264 (AVC)", - av1: "AV1", + +// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS +// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is +// a real preference that simply isn't plugged in right now, and dropping it would silently +// re-point the next stream at the default without ever showing the user why. +const DeviceRow: FC<{ + label: string; + description: string; + devices: AudioDevice[] | null; + value: string; + onChange: (v: string) => void; + disabled?: boolean; + indent?: boolean; +}> = ({ label, description, devices, value, onChange, disabled, indent }) => { + const options: [string, string][] = [["", "System default"]]; + for (const d of devices ?? []) options.push([d.name, d.description]); + if (value && !options.some(([name]) => name === value)) { + options.push([value, `${value} (not connected)`]); + } + return ( + + ); }; -const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"]; -const COMPOSITOR_LABELS: Record = { - auto: "Automatic", - kwin: "KDE Plasma (KWin)", - wlroots: "Sway (wlroots)", - mutter: "GNOME (Mutter)", - gamescope: "gamescope", + +// ---------------------------------------------------------------------------------------- +// The pages. One settings object, seven views on it — every page takes the same context rather +// than fetching or holding state of its own, so a change on one page is visible on the others +// the moment you switch. +// ---------------------------------------------------------------------------------------- + +interface PageCtx { + s: StreamSettings; + patch: (p: Partial) => void; + devices: DeviceLists | null; + reading: boolean; + readDevices: (again: boolean) => void; +} + +// SidebarNavigation gives each page Steam's own padding, but the routed page still renders +// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same +// inset the tabs use). +const pageBody: CSSProperties = { paddingBottom: "80px" }; + +const StreamPage: FC = ({ s, patch }) => { + const renderScale = s.render_scale ?? 1; + const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height); + return ( +
+ [resolutionKey(w, h), label] as [string, string]), + [MATCH_WINDOW, "Match window"] as [string, string], + ]} + value={resolution} + // A size set from a desktop profile that isn't one of these presets, spelled the way the + // presets are rather than left as the raw "1600x900" key. + formatUnknown={(v) => v.replace("x", " × ")} + onChange={(v) => { + if (v === MATCH_WINDOW) { + // The tri-state the console stores: the flag on, the explicit size cleared. + patch({ match_window: true, width: 0, height: 0 }); + return; + } + const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v); + patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 }); + }} + /> + [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])} + value={s.refresh_hz} + formatUnknown={(v) => `${v} Hz`} + onChange={(v) => patch({ refresh_hz: v })} + /> + [x, renderScaleLabel(x)] as [number, string])} + // Snap the stored value to the nearest preset so the dropdown always shows a match. + value={RENDER_SCALES.reduce((best, x) => + Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best, + )} + onChange={(v) => patch({ render_scale: v })} + /> + patch({ bitrate_kbps: v * 1000 })} + /> + patch({ compositor: v })} + /> +
+ ); }; +const VideoPage: FC = ({ s, patch, devices }) => { + // Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a + // picker with a single option is a control that can't do anything. + const showGpuRow = (devices?.adapters.length ?? 0) > 1; + return ( +
+ patch({ codec: v })} + /> + patch({ decoder: v })} + /> + {showGpuRow && ( + [a, a] as [string, string]), + ]} + value={s.adapter ?? ""} + onChange={(v) => patch({ adapter: v })} + /> + )} + patch({ hdr_enabled: v })} + /> + patch({ enable_444: v })} + /> +
+ ); +}; + +const PresentationPage: FC = ({ s, patch }) => { + const smooth = (s.present_priority ?? "latency") === "smooth"; + return ( +
+ patch({ present_priority: v })} + /> + `${v} frames`} + onChange={(v) => patch({ smooth_buffer: v })} + disabled={!smooth} + indent + /> + patch({ vsync: v })} + /> + patch({ allow_vrr: v })} + /> +
+ ); +}; + +const AudioPage: FC = ({ s, patch, devices, reading, readDevices }) => { + const micOn = s.mic_enabled; + // What the pickers get: null while the enumeration is in flight (they show a loading state), + // [] when it answered but couldn't read the endpoints (System default plus whatever is + // stored), and the real list otherwise. + const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null => + reading || !devices ? null : devices.ok ? (list ?? []) : []; + return ( +
+ `${v} channels`} + onChange={(v) => patch({ audio_channels: v })} + /> + patch({ speaker_device: v })} + /> + patch({ mic_enabled: v })} + /> + patch({ mic_device: v })} + disabled={!micOn} + indent + /> + patch({ echo_cancel: v })} + disabled={!micOn} + indentLevel={1} + /> + {/* The escape hatch for a headset plugged in after this page was opened, and the honest + answer when the enumeration failed outright (a client too old to ship the session + binary). Rendered unconditionally, including while it is reading: a row that comes and + goes under a thumbstick is a moving target, so only its wording changes. */} + + + readDevices(true)}> + {reading ? : "Refresh"} + + + +
+ ); +}; + +const ControllersPage: FC = ({ s, patch }) => { + const forwarding = s.gamepad_forwarding ?? true; + return ( +
+ patch({ gamepad_forwarding: v })} + /> + patch({ gamepad: v })} + disabled={!forwarding} + indent + /> + {forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && ( + + )} +
+ ); +}; + +const PointerPage: FC = ({ s, patch }) => ( +
+ patch({ touch_mode: v })} + /> + patch({ mouse_mode: v })} + /> + patch({ invert_scroll: v })} + /> + patch({ inhibit_shortcuts: v })} + /> +
+); + +const InterfacePage: FC = ({ s, patch }) => { + // `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool, + // which itself defaults to true. + const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off"); + return ( +
+ patch({ stats_verbosity: v, show_stats: v !== "off" })} + /> + patch({ auto_wake: v })} + /> + patch({ library_enabled: v })} + /> + patch({ fullscreen_on_stream: v })} + /> +
+ ); +}; + +// ---------------------------------------------------------------------------------------- + export const SettingsSection: FC = () => { const [s, setS] = useState(null); + // null until the enumeration answers — the pickers show a loading state rather than briefly + // claiming this device has no endpoints. + const [devices, setDevices] = useState(null); + const [reading, setReading] = useState(true); + + const readDevices = (again: boolean) => { + setReading(true); + void (again ? refreshDevices() : listDevices()) + .then(setDevices) + .finally(() => setReading(false)); + }; useEffect(() => { void getSettings().then(setS); + // Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan + // takes seconds, and the rest of the screen must not wait for it. + readDevices(false); }, []); const patch = (p: Partial) => { @@ -74,138 +616,42 @@ export const SettingsSection: FC = () => { if (!s) return ; - const resIdx = Math.max( - 0, - RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), - ); - + const ctx: PageCtx = { s, patch, devices, reading, readDevices }; return ( - <> - - -
- ({ data: i, label }))} - selectedOption={resIdx} - onChange={(o) => { - const [w, h] = RESOLUTIONS[o.data as number]; - patch({ width: w, height: h }); - }} - /> -
-
-
- - -
- ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} - selectedOption={s.refresh_hz} - onChange={(o) => patch({ refresh_hz: o.data as number })} - /> -
-
-
- - -
- ({ data: x, label: renderScaleLabel(x) }))} - // Snap the stored value to the nearest preset so the dropdown always shows a match. - selectedOption={RENDER_SCALES.reduce((best, x) => - Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best, - )} - onChange={(o) => patch({ render_scale: o.data as number })} - /> -
-
-
- patch({ bitrate_kbps: v * 1000 })} - /> - - -
- ({ data: c, label: CODEC_LABELS[c] ?? c }))} - selectedOption={s.codec ?? "auto"} - onChange={(o) => patch({ codec: o.data as string })} - /> -
-
-
- patch({ gamepad_forwarding: v })} - /> - {(s.gamepad_forwarding ?? true) && ( - <> - - -
- ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} - selectedOption={s.gamepad} - onChange={(o) => patch({ gamepad: o.data as string })} - /> -
-
-
- {(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( - - )} - - )} - - -
- ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} - selectedOption={s.compositor} - onChange={(o) => patch({ compositor: o.data as string })} - /> -
-
-
- patch({ mic_enabled: v })} - /> - + , content: }, + { title: "Video", identifier: "video", icon: , content: }, + { + title: "Presentation", + identifier: "presentation", + icon: , + content: , + }, + { title: "Audio", identifier: "audio", icon: , content: }, + { + title: "Controllers", + identifier: "controllers", + icon: , + content: , + }, + { + title: "Touch & mouse", + identifier: "pointer", + icon: , + content: , + }, + { + title: "Interface", + identifier: "interface", + icon: , + content: , + }, + ]} + /> ); }; diff --git a/crates/punktfunk-core/src/abr.rs b/crates/punktfunk-core/src/abr.rs index f194d7e0..d0637334 100644 --- a/crates/punktfunk-core/src/abr.rs +++ b/crates/punktfunk-core/src/abr.rs @@ -24,17 +24,19 @@ //! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency //! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion //! (heavy-but-recoverable loss, an OWD rise, a decode rise) needs two consecutive bad windows. -//! Recovery is two-mode: **slow start** — until the first congestion signal the rate DOUBLES each -//! clean window (cooldown-paced), which is how an Automatic session climbs from the conservative -//! start to the [`set_ceiling`](BitrateController::set_ceiling) measured by the startup -//! link-capacity probe in seconds instead of minutes — then classic additive recovery (+~6 % -//! after ~4.5 s clean, ceilinged). Changes are rate-limited (each one costs the IDR the host's +//! Recovery is two-mode: **slow start** — until the first congestion signal each clean window +//! asks for double the current rate, bounded (like every climb) by the proven-throughput +//! headroom below, so the step a loaded session actually takes is ×1.5 over what it last +//! delivered; either way it climbs from the conservative start to the +//! [`set_ceiling`](BitrateController::set_ceiling) measured by the startup link-capacity probe +//! in seconds rather than minutes — then classic additive recovery (+~6 % after ~4.5 s clean, +//! ceilinged). Changes are rate-limited (each one costs the IDR the host's //! rebuilt encoder opens with) and the whole controller disables itself against a host that never //! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages). //! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the //! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate //! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly -//! ([`CAP_REPROBE_WINDOWS`]) so neither latch outlives the condition that taught it. +//! ([`CAP_REPROBE_WINDOWS_MIN`]) so neither latch outlives the condition that taught it. //! //! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder — //! how many bits it actually emits depends on the content — so on calm content (a menu, an idle @@ -128,15 +130,26 @@ const ENCODE_RISE_US: i64 = 4_000; /// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode /// queue is growing past the knee; skip the two-window confirmation. const ENCODE_SEVERE_US: i64 = 12_000; -/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before -/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent -/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole -/// session. A still-standing limit just re-teaches itself in two short acks, which the host -/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR. -/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same clock for the -/// same reason: the decoder's knee moves with content and thermals, so its latch must not be -/// permanent either. -const CAP_REPROBE_WINDOWS: u32 = 80; +/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that +/// interval backs off to. +/// +/// A learned cap is EVIDENCE, not a spec limit: the host's short ack means "not right now", +/// which covers both its encoder's codec-level ceiling (durable) and a climb refused while +/// encode is behind cadence (transient, and routinely latched during slow start at the +/// conservative 20 Mbps default). The client cannot tell those apart from the ack alone, so the +/// re-probe is what keeps a transient from becoming the session's ceiling — and a flat ~60 s +/// clock at +12.5 % made that escape take upwards of twenty minutes to cross the gap to a +/// probe-measured link ceiling, which is indistinguishable from never. +/// +/// So: probe again after 12 s, and DOUBLE the interval each time the lift is immediately +/// re-learned at the same value (see [`on_ack`](BitrateController::on_ack)). A transient is out +/// in one interval; a standing limit settles into a slow poll instead of a permanent one. The +/// re-probe itself is nearly free either way — a still-standing limit re-teaches itself in two +/// short acks, which the host pre-clamps without touching the encoder: no rebuild, no IDR. +/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same schedule for +/// the same reason: the decoder's knee moves with content and thermals. +const CAP_REPROBE_WINDOWS_MIN: u32 = 16; +const CAP_REPROBE_WINDOWS_MAX: u32 = 128; /// Two consecutive decode-driven backoffs latch the /// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree /// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its @@ -146,6 +159,17 @@ const DECODE_CAP_SIMILAR_DIV: u32 = 8; /// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline. /// Long enough to remember the uncongested floor, short enough to follow genuine path changes. const BASELINE_WINDOWS: usize = 40; +/// Windows a rolling baseline must hold before the signal it feeds may fire. A baseline is a +/// rolling MINIMUM, so a single sample IS the baseline — and if that one window landed on calm +/// content, ordinary content variance clears the rise threshold by itself. That hole is not +/// theoretical: [`on_ack`](BitrateController::on_ack) deliberately CLEARS the encode baseline +/// after every decrease we ourselves asked for, so the encode down-driver re-armed on a +/// one-sample floor each time — a calm re-seed window followed by a motion scene reads as +/// `ENCODE_RISE_US` of "congestion", backs off, clears again, and ratchets to the floor on a +/// link that was never the problem. Four windows (3 s) of evidence before any of the three +/// latency signals may fire costs a little reaction latency at session start and buys a floor +/// that means something. +const BASELINE_MIN_WINDOWS: usize = 4; /// Requests sent without a single [`crate::quic::BitrateChanged`] ack before concluding the host /// predates bitrate renegotiation and going quiet for the rest of the session. const MAX_UNACKED: u32 = 3; @@ -167,6 +191,37 @@ fn ceiling_cap_from_env() -> Option { .map(|m| m.saturating_mul(1_000)) } +/// Score one window's latency sample against its rolling-min baseline, then record it. +/// +/// Shared by all three latency signals (OWD, client decode, host encode) — same shape, different +/// thresholds. `mean` is `None` when nobody reports the signal (no clock handshake, an embedder +/// that doesn't measure decode, a host that ships no stage timings); the signal is then simply +/// absent rather than clean, so it can neither mark a window bad nor teach a baseline. +/// +/// The baseline is the minimum of the PRIOR windows — this window is compared before it is +/// recorded, so a rising window can't drag its own floor up with it — and only counts once +/// [`BASELINE_MIN_WINDOWS`] of them exist. Returns `(rise, severe)`; pass `i64::MAX` for +/// `severe_us` on a signal with no severe tier. +fn score_baseline( + means: &mut VecDeque, + mean: Option, + rise_us: i64, + severe_us: i64, +) -> (bool, bool) { + let Some(mean) = mean else { + return (false, false); + }; + let base = (means.len() >= BASELINE_MIN_WINDOWS) + .then(|| means.iter().min().copied()) + .flatten(); + let over = |t: i64| base.is_some_and(|b| mean > b.saturating_add(t)); + if means.len() == BASELINE_WINDOWS { + means.pop_front(); + } + means.push_back(mean); + (over(rise_us), over(severe_us)) +} + /// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`]. pub(crate) struct BitrateController { /// `false` = permanently off (explicit user bitrate, an old host, or ack silence). @@ -199,7 +254,7 @@ pub(crate) struct BitrateController { /// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a /// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so /// the probe-measured link authority survives a mode switch's reset. Slowly re-probed - /// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever. + /// ([`CAP_REPROBE_WINDOWS_MIN`]) so scene-dependent evidence can't cap the session forever. host_cap_kbps: Option, /// The rate the last [`request`](Self::request) asked for — the reference an ack is judged /// short against. Taken (not kept) by the ack, so one request is judged at most once. @@ -210,8 +265,11 @@ pub(crate) struct BitrateController { /// deterministic min()s, so a persistent limit reproduces exactly. short_ack_kbps: u32, short_acks: u32, - /// Clean windows spent parked at the learned cap (the re-probe clock). + /// Clean windows spent parked at the learned cap (the re-probe clock) and the interval it is + /// counting toward — [`CAP_REPROBE_WINDOWS_MIN`], doubled toward + /// [`CAP_REPROBE_WINDOWS_MAX`] each time a lift is immediately re-learned. cap_probe_windows: u32, + cap_reprobe_after: u32, /// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the /// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe /// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the @@ -220,7 +278,7 @@ pub(crate) struct BitrateController { /// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling /// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the /// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed - /// on the [`CAP_REPROBE_WINDOWS`] clock, exactly like the host cap, so a decoder that + /// on the [`CAP_REPROBE_WINDOWS_MIN`] clock, exactly like the host cap, so a decoder that /// recovers (lighter content, thermal headroom) climbs again — the latch is never /// permanent. decode_cap_kbps: Option, @@ -228,8 +286,10 @@ pub(crate) struct BitrateController { /// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`]) /// to latch the cap — one spurious flush teaches nothing. decode_backoff_kbps: u32, - /// Clean windows spent parked at the learned decode cap (its re-probe clock). + /// Clean windows spent parked at the learned decode cap (its re-probe clock), and that + /// clock's own backoff interval — same schedule as the host cap's. decode_cap_probe_windows: u32, + decode_cap_reprobe_after: u32, /// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat /// decode latency — the known-good high-water mark climbs are bounded against. Never decays; /// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On @@ -241,6 +301,10 @@ pub(crate) struct BitrateController { last_change: Option, /// Requests since the last ack — reaching [`MAX_UNACKED`] disables the controller. unacked: u32, + /// The last ceiling-clamp target asked for (0 = none). A session running ABOVE its effective + /// ceiling is asked down to it exactly once per distinct target — a host that answers higher + /// has said it cannot go there, and re-asking every cooldown only costs reconfigures. + ceiling_ask_kbps: u32, } impl BitrateController { @@ -257,7 +321,12 @@ impl BitrateController { BitrateController { enabled: start_kbps > 0, current_kbps: start_kbps, - ceiling_kbps: start_kbps, + // The env cap binds the NEGOTIATED ceiling too, not just probe-learned ones. It is + // the only lever an Automatic session gives the operator (Automatic is precisely + // "no explicit bitrate"), so a start rate above it has to come down rather than + // stand as a ceiling the user asked not to reach — see the clamp-down step in + // [`on_window`](Self::on_window). + ceiling_kbps: start_kbps.min(ceiling_cap_kbps.unwrap_or(u32::MAX)), ceiling_cap_kbps, floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)), probing: true, @@ -269,14 +338,17 @@ impl BitrateController { short_ack_kbps: 0, short_acks: 0, cap_probe_windows: 0, + cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN, decode_cap_kbps: None, decode_backoff_kbps: 0, decode_cap_probe_windows: 0, + decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN, proven_kbps: 0, bad_windows: 0, clean_windows: 0, last_change: None, unacked: 0, + ceiling_ask_kbps: 0, } } @@ -321,8 +393,21 @@ impl BitrateController { self.short_acks = 1; } if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) { + // Re-learning a cap we had already lifted means the limit is STANDING, + // not the transient the re-probe exists to escape — back its clock off + // (see [`CAP_REPROBE_WINDOWS_MIN`]) so a hard encoder ceiling settles + // into a slow poll instead of two pointless acks every 12 s. A first + // latch starts the clock fast, because that is the case that matters. + self.cap_reprobe_after = if self.host_cap_kbps.is_some() { + self.cap_reprobe_after + .saturating_mul(2) + .min(CAP_REPROBE_WINDOWS_MAX) + } else { + CAP_REPROBE_WINDOWS_MIN + }; tracing::info!( cap_kbps = kbps, + reprobe_after_windows = self.cap_reprobe_after, "adaptive bitrate: host cap learned (encoder ceiling or cadence \ refusal) — climbs stop here until it lifts" ); @@ -331,9 +416,34 @@ impl BitrateController { } } else { self.short_acks = 0; + // GRANTED in full at or above the learned cap: the limit that taught it is + // gone, and we have the host's own word for it. Drop the cap outright rather + // than keep crawling up in +12.5 % re-probe steps — for a cap latched from a + // transient (a host briefly behind cadence) that crawl is the entire + // remaining cost of the transient, and it is measured in minutes. + if self.host_cap_kbps.is_some_and(|c| kbps >= c) { + tracing::info!( + granted_kbps = kbps, + "adaptive bitrate: host granted a climb at the learned cap — the \ + limit has lifted, dropping it" + ); + self.host_cap_kbps = None; + self.cap_probe_windows = 0; + self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN; + } } } self.current_kbps = kbps; + // The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited + // `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually + // encodes (a 1080p session mirroring a 4K panel resolves ~3× higher), and that is + // the host's own Automatic answer, not a climb we asked for. Let the ceiling follow + // — `set_ceiling` only ever raises, and still clamps to the operator's + // `PUNKTFUNK_ABR_MAX_MBPS`, which is what must bind here if anything does. Without + // this the ceiling stays at the stale negotiated rate and the step-down below + // immediately drags the host back off the rate it just chose. A no-op for ordinary + // acks: we never request above the effective ceiling in the first place. + self.set_ceiling(kbps); } self.unacked = 0; } @@ -343,14 +453,30 @@ impl BitrateController { /// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so /// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property) /// survives. + /// + /// Every rolling BASELINE is mode-scoped too, and for the same reason the encode one always + /// was: a mode switch changes what "normal" costs at both ends of the pipe. 4K120 decodes + /// and encodes far slower than 1080p60 and puts bigger frames on the wire, so a baseline + /// learned under the old mode is a floor the new one clears on its very first window — + /// [`DECODE_RISE_US`] is 15 µs-thousands, well inside the gap between those two modes. Left + /// standing (only `encode_means` used to be cleared here), the ~30 s it takes + /// [`BASELINE_WINDOWS`] to age out is ~30 s of every window scoring bad, which is a ×0.7 + /// backoff every other window: a switch UP in mode cratered the rate instead of raising it. + /// `proven_kbps` goes with them — it is the mark climbs are bounded against, and throughput + /// the OLD mode's decoder digested is not evidence about this one. It re-earns itself from + /// the next window. pub(crate) fn on_mode_switch(&mut self) { self.host_cap_kbps = None; self.short_acks = 0; self.cap_probe_windows = 0; + self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN; self.decode_cap_kbps = None; self.decode_backoff_kbps = 0; self.decode_cap_probe_windows = 0; + self.owd_means.clear(); + self.decode_means.clear(); self.encode_means.clear(); + self.proven_kbps = 0; } /// Feed one report window; returns the rate to request now, if any. `dropped` = frames that @@ -389,22 +515,9 @@ impl BitrateController { return None; } // OWD: compare against the rolling-min baseline of PRIOR windows (so a rising window - // doesn't drag its own baseline up), then record it. - let owd_bad = match owd_mean_us { - Some(mean) => { - let bad = self - .owd_means - .iter() - .min() - .is_some_and(|&base| mean > base + OWD_RISE_US); - if self.owd_means.len() == BASELINE_WINDOWS { - self.owd_means.pop_front(); - } - self.owd_means.push_back(mean); - bad - } - None => false, - }; + // doesn't drag its own baseline up), then record it. No severe tier — a standing queue is + // congestion evidence, not visible damage, so it always takes the two-window path. + let (owd_bad, _) = score_baseline(&mut self.owd_means, owd_mean_us, OWD_RISE_US, i64::MAX); // Decode-stage latency: same rolling-min-baseline treatment as OWD, but measuring the // CLIENT'S decoder rather than the link. A rise means the decoder is backlogging frames — // the bottleneck the network signals are blind to. Marking the window bad both ends slow @@ -412,43 +525,22 @@ impl BitrateController { // the link ceiling) and, sustained, drives the ×0.7 backoff down to the real decode limit. // An excursion far past baseline is SEVERE: the decoder is deep in spike-overload and the // user is watching it — skip the two-window confirmation. - let (decode_bad, decode_severe) = match decode_mean_us { - Some(mean) => { - let base = self.decode_means.iter().min().copied(); - let bad = base.is_some_and(|b| mean > b + DECODE_RISE_US); - let severe = base.is_some_and(|b| mean > b + DECODE_SEVERE_US); - if self.decode_means.len() == BASELINE_WINDOWS { - self.decode_means.pop_front(); - } - self.decode_means.push_back(mean); - (bad, severe) - } - None => (false, false), - }; + let (decode_bad, decode_severe) = score_baseline( + &mut self.decode_means, + decode_mean_us, + DECODE_RISE_US, + DECODE_SEVERE_US, + ); // Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S // encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only // signal that can push an already-too-high rate back under the knee: the host refuses // further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN. - let (encode_bad, encode_severe) = match encode_mean_us { - Some(mean) => { - let base = self.encode_means.iter().min().copied(); - let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US); - let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US); - if self.encode_means.len() == BASELINE_WINDOWS { - self.encode_means.pop_front(); - } - self.encode_means.push_back(mean); - (bad, severe) - } - None => (false, false), - }; - // The proven-throughput high-water mark: this window's delivered rate is now demonstrably - // digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify — - // the bytes that DID arrive still went through the decoder; what loss means for the rate - // is the bad/severe machinery's business. - if !decode_bad && actual_kbps > self.proven_kbps { - self.proven_kbps = actual_kbps; - } + let (encode_bad, encode_severe) = score_baseline( + &mut self.encode_means, + encode_mean_us, + ENCODE_RISE_US, + ENCODE_SEVERE_US, + ); // SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a // deep decode-latency excursion, a window spent begging for keyframes) or loss far past // any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an @@ -466,6 +558,17 @@ impl BitrateController { || decode_bad || encode_bad || recovery_kf >= RECOVERY_KF_BAD; + // The proven-throughput high-water mark: this window's delivered rate is now demonstrably + // digestible — the pipeline carried it and NOTHING went wrong while it did. Scored after + // the verdict and gated on the whole of it, not on decode alone: the mark never decays, so + // one window is permanent authority over how far every later climb may step, and the + // windows that overstate delivered throughput are exactly the damaged ones (a stall's + // backlog draining in a single window, a flush's queue, the FEC surge that answers a loss + // burst). "Loss doesn't disqualify, the bytes still arrived" was true about the bytes and + // wrong about the conclusion drawn from them. + if !bad && actual_kbps > self.proven_kbps { + self.proven_kbps = actual_kbps; + } if bad { self.bad_windows += 1; self.clean_windows = 0; @@ -475,16 +578,16 @@ impl BitrateController { self.clean_windows += 1; self.bad_windows = 0; } - // The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean - // windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a - // scene-dependent refusal can't quietly cap the whole session — a still-standing limit - // just re-latches from the next pair of short acks, at zero encoder cost. + // The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run + // parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent + // refusal can't quietly cap the whole session — a still-standing limit just re-latches + // from the next pair of short acks, at zero encoder cost, and backs the clock off. if let Some(cap) = self.host_cap_kbps { if bad { self.cap_probe_windows = 0; } else if self.current_kbps >= cap.saturating_sub(cap / 16) { self.cap_probe_windows += 1; - if self.cap_probe_windows >= CAP_REPROBE_WINDOWS { + if self.cap_probe_windows >= self.cap_reprobe_after { self.cap_probe_windows = 0; let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps); if lifted > cap { @@ -508,7 +611,7 @@ impl BitrateController { self.decode_cap_probe_windows = 0; } else if self.current_kbps >= cap.saturating_sub(cap / 16) { self.decode_cap_probe_windows += 1; - if self.decode_cap_probe_windows >= CAP_REPROBE_WINDOWS { + if self.decode_cap_probe_windows >= self.decode_cap_reprobe_after { self.decode_cap_probe_windows = 0; let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps); if lifted > cap { @@ -538,18 +641,42 @@ impl BitrateController { // knee. One event never latches (a spurious flush must stay a one-off), and a // backoff without decode evidence in between breaks the streak — whatever it saw, // it wasn't the same knee. - if decode_severe || flushed { + // A bare flush counts as decode evidence only where the decode signal can't speak + // for itself. On an embedder that reports decode latency, a flush with FLAT decode + // is a network event (a stall, a clock step) that drained a queue the decoder was + // keeping up with — teaching a "decoder knee" from it caps the session on the wrong + // end of the pipe. Where the signal is absent the old reading stands: the flush is + // the only decoder-saturation evidence there is. + let decode_evidence = + decode_severe || (flushed && (decode_bad || decode_mean_us.is_none())); + if decode_evidence { let rate = self.current_kbps; let similar = self.decode_backoff_kbps > 0 && rate.abs_diff(self.decode_backoff_kbps) <= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV; - if similar && self.decode_cap_kbps.is_none_or(|c| rate < c) { + // Latch just UNDER the rate that choked, not at it: the knee is the rate the + // decoder could not hold, so a cap sitting exactly on it authorizes climbing + // straight back into the failure — the sawtooth the cap exists to end, merely + // slower. One sixteenth is inside the ±1/8 band the pair had to agree within, + // so it costs nothing the evidence actually established. + let knee = rate.saturating_sub(rate / 16).max(self.floor_kbps); + if similar && self.decode_cap_kbps.is_none_or(|c| knee < c) { + // Same standing-vs-transient backoff as the host cap. + self.decode_cap_reprobe_after = if self.decode_cap_kbps.is_some() { + self.decode_cap_reprobe_after + .saturating_mul(2) + .min(CAP_REPROBE_WINDOWS_MAX) + } else { + CAP_REPROBE_WINDOWS_MIN + }; tracing::info!( - cap_kbps = rate, + cap_kbps = knee, + choked_at_kbps = rate, + reprobe_after_windows = self.decode_cap_reprobe_after, "adaptive bitrate: decode cap learned (decoder knee) — climbs stop \ here until it lifts" ); - self.decode_cap_kbps = Some(rate.max(self.floor_kbps)); + self.decode_cap_kbps = Some(knee); self.decode_cap_probe_windows = 0; } self.decode_backoff_kbps = rate; @@ -574,6 +701,23 @@ impl BitrateController { .ceiling_kbps .min(self.host_cap_kbps.unwrap_or(u32::MAX)) .min(self.decode_cap_kbps.unwrap_or(u32::MAX)); + // Above the ceiling with nothing wrong: the session negotiated a rate the operator's + // `PUNKTFUNK_ABR_MAX_MBPS` forbids (no congestion signal will ever find this — the link + // is fine, the cap is a policy). Step straight to it rather than sitting above a limit + // the user set, and never below the floor. Asked ONCE per distinct target: if the host + // answers with something higher it has told us it cannot go there (its own floor, an + // encoder minimum), and repeating the ask every cooldown would buy nothing but a + // reconfigure each time. + let ceiling_target = eff_ceiling.max(self.floor_kbps); + if self.current_kbps > ceiling_target && self.ceiling_ask_kbps != ceiling_target { + tracing::info!( + from_kbps = self.current_kbps, + to_kbps = ceiling_target, + "adaptive bitrate: session rate is above the configured ceiling — stepping down" + ); + self.ceiling_ask_kbps = ceiling_target; + return self.request(ceiling_target, now); + } let cap = eff_ceiling .min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN); if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps { @@ -602,6 +746,17 @@ impl BitrateController { // request just recomputes from the same base next time (and counts toward MAX_UNACKED). Some(kbps) } + + /// The decision [`on_window`](Self::on_window) returned never reached the wire (the control + /// queue was full). Undo the request's bookkeeping: [`MAX_UNACKED`] exists to detect a HOST + /// that doesn't answer, and counting a message we never sent toward it retires the + /// controller for the session — with a log line blaming an "older host" that is not what + /// happened. Clearing the pending request also keeps a later unsolicited ack from being + /// judged short against a rate we never asked for. + pub(crate) fn on_request_dropped(&mut self) { + self.unacked = self.unacked.saturating_sub(1); + self.last_requested_kbps = None; + } } #[cfg(test)] @@ -1110,14 +1265,16 @@ mod tests { #[test] fn decode_latency_caps_the_slow_start_climb() { - // A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate. + // A fat link (probe measured ~300 Mbps) but a decoder that saturates below it. let mut c = BitrateController::new(20_000); c.set_ceiling(300_000); let start = Instant::now(); - // First clean window (decoder fine at 20 Mbps) → slow start doubles to 40. - assert_eq!( - c.on_window( - ticks(start, 0), + // Slow start doubles while the decoder keeps up, and the first BASELINE_MIN_WINDOWS of + // those windows are what teach the decode baseline (one sample is not a floor). + let mut last = 0; + for i in 0..BASELINE_MIN_WINDOWS as u32 { + if let Some(k) = c.on_window( + ticks(start, i * 2), 0, 0, Some(10_000), @@ -1125,16 +1282,18 @@ mod tests { None, 1_000_000, false, - 0 - ), - Some(40_000) - ); - c.on_ack(40_000); - // At 40 Mbps the decoder starts backing up (30 ms over baseline): the window is bad, so the - // climb stops here instead of doubling on toward the 300 Mbps link ceiling… + 0, + ) { + last = k; + c.on_ack(k); + } + } + assert_eq!(last, 300_000, "slow start should reach the probed ceiling"); + // Now the decoder starts backing up (30 ms over the learned baseline): the window is bad, + // so the climb stops instead of parking at the link ceiling… assert_eq!( c.on_window( - ticks(start, 2), + ticks(start, 20), 0, 0, Some(10_000), @@ -1146,11 +1305,11 @@ mod tests { ), None ); - // …and a second backed-up window backs the rate off, settling at the decode limit rather + // …and a second backed-up window backs the rate off toward the real decode limit rather // than choking the decoder at the link ceiling (the reported bug). assert_eq!( c.on_window( - ticks(start, 4), + ticks(start, 22), 0, 0, Some(10_000), @@ -1160,7 +1319,54 @@ mod tests { false, 0 ), - Some(28_000) + Some(210_000) + ); + } + + #[test] + fn one_calm_window_is_not_a_baseline() { + // The ratchet this guard exists to stop: our own decrease CLEARS the encode baseline, so + // it re-seeds from whatever the next window happens to be. If that window is calm, the + // ordinary content variance that follows reads as a rise, backs off, clears again — all + // the way to the floor on a link that was never the problem. A single sample must not + // arm the signal. + let mut c = BitrateController::new(100_000); + let start = Instant::now(); + // One calm 3 ms encode window, then windows 9 ms above it: far past ENCODE_RISE_US, and + // sustained — yet no baseline exists to judge them against yet. + for i in 0..BASELINE_MIN_WINDOWS as u32 { + let mean = if i == 0 { 3_000 } else { 12_000 }; + assert_eq!( + c.on_window( + ticks(start, i), + 0, + 0, + Some(10_000), + None, + Some(mean), + 1_000_000, + false, + 0 + ), + None, + "window {i} fired off a baseline of fewer than {BASELINE_MIN_WINDOWS} samples" + ); + } + // With a real baseline (min 3 ms over 4 windows) the signal works exactly as before: a + // sustained rise past it still backs the rate off. + assert_eq!( + c.on_window( + ticks(start, 8), + 0, + 0, + Some(10_000), + None, + Some(20_000), + 1_000_000, + false, + 0 + ), + Some(70_000) ); } @@ -1385,8 +1591,8 @@ mod tests { #[test] fn learned_cap_reprobes_after_a_sustained_clean_run() { - // A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean - // at the cap, lift one step so a one-time heavy scene can't cap the session forever. A + // A cadence-refusal cap is scene evidence, not a spec limit: after a clean run parked at + // the cap, lift one step so a one-time heavy scene can't cap the session forever. A // still-standing limit just re-latches from the next short-ack pair, at zero cost. let mut c = BitrateController::new(400_000); c.set_ceiling(1_400_000); @@ -1396,7 +1602,10 @@ mod tests { assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000)); c.on_ack(794_000); assert_eq!(c.host_cap_kbps, Some(794_000)); - for i in 0..CAP_REPROBE_WINDOWS { + // The FIRST re-probe is the fast one — a transient refusal must not cost the session + // minutes to escape. + assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN); + for i in 0..CAP_REPROBE_WINDOWS_MIN { let _ = c.on_window( ticks(start, 20 + i), 0, @@ -1412,6 +1621,130 @@ mod tests { assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8)); } + #[test] + fn a_transient_refusal_does_not_pin_the_session() { + // The field failure this whole cap-escape change exists for. A host that escalates its + // capture/encode pipeline once — a startup hitch is enough — used to refuse every climb + // for the rest of the session; the client latched that refusal as a cap, at whatever + // rate slow start had reached, which is routinely the 20 Mbps default. Escaping cost + // +12.5 % per ~60 s: north of twenty minutes to reach a 300 Mbps link ceiling, which the + // user experiences as "Automatic is broken". + let mut c = BitrateController::new(20_000); + c.set_ceiling(300_000); // the startup probe measured a fat link + let start = Instant::now(); + let mut tick = 0u32; + let mut windows_pinned = 0u32; + // Two refused climbs at the same rate → the cap latches at 20 Mbps. + for _ in 0..2 { + let k = run_clean(&mut c, start, tick, 4).expect("slow start should ask to climb"); + tick += 4; + assert!(k > 20_000); + c.on_ack(20_000); // "behind cadence — held at the current rate" + } + assert_eq!(c.host_cap_kbps, Some(20_000)); + // The host recovers immediately (its bucket drains; the escalation bought the headroom + // it was for), but the client has no way to know that except by asking again. Drive + // clean windows and grant whatever it asks for. + while c.current_kbps < 150_000 && windows_pinned < 400 { + if let Some(k) = c.on_window( + ticks(start, tick), + 0, + 0, + Some(10_000), + None, + None, + 1_000_000, + false, + 0, + ) { + c.on_ack(k); + } + tick += 1; + windows_pinned += 1; + } + assert!( + c.current_kbps >= 150_000, + "still pinned at {} after {windows_pinned} windows", + c.current_kbps + ); + // ~750 ms a window: this must be tens of seconds, not the old tens of minutes. + assert!( + windows_pinned <= 40, + "took {windows_pinned} windows (~{} s) to escape a transient refusal", + windows_pinned * 3 / 4 + ); + // And the disproven cap is gone, not merely nudged upward. + assert!(c.host_cap_kbps.is_none()); + } + + #[test] + fn a_host_retarget_above_the_ceiling_raises_it() { + // The host sends an unsolicited `BitrateChanged` when a rebuild re-resolves an Automatic + // rate for what it ACTUALLY encodes — a 1080p session mirroring a 4K panel resolves far + // above the negotiated rate. That is the host's own Automatic answer, so the climb + // ceiling has to follow it; otherwise the ceiling stays stale and the step-down drags + // the host straight back off the rate it just chose. + let mut c = BitrateController::new(20_000); + assert_eq!(c.ceiling_kbps, 20_000); + c.on_ack(60_000); // unsolicited: no request was outstanding + assert_eq!(c.current_kbps, 60_000); + assert_eq!(c.ceiling_kbps, 60_000); + let start = Instant::now(); + // No step-down, and no spurious re-target of any kind. + assert_eq!(run_clean(&mut c, start, 0, 4), None); + // The operator's cap still outranks it — that is the one thing that must bind here. + let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000)); + c.on_ack(60_000); + assert_eq!(c.ceiling_kbps, 50_000); + assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000)); + } + + #[test] + fn a_standing_cap_backs_its_reprobe_clock_off() { + // The other half of the re-probe: an encoder's real codec ceiling (794 Mbps, L6.2) + // re-teaches itself every time the lift is tried. Escaping fast is right for a + // transient and pointless here, so each re-learn doubles the interval — a hard limit + // settles into a slow poll instead of two acks every 12 s for the whole session. + let mut c = BitrateController::new(400_000); + c.set_ceiling(1_400_000); + let start = Instant::now(); + assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000)); + c.on_ack(794_000); + assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000)); + c.on_ack(794_000); + assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN); + // Each round: park clean at the cap until it re-probes upward, then have the host refuse + // the lift at the same value again. That is a STANDING limit, so the clock doubles. + let mut tick = 20; + for round in 0..3 { + let before = c.cap_reprobe_after; + for _ in 0..before { + let _ = c.on_window( + ticks(start, tick), + 0, + 0, + Some(10_000), + None, + None, + 1_000_000, + false, + 0, + ); + tick += 1; + } + let lifted = c.host_cap_kbps.expect("cap should still be latched"); + assert!(lifted > 794_000, "round {round}: the re-probe never lifted"); + // The host clamps the lift straight back to its real ceiling. + c.last_requested_kbps = Some(lifted); + c.on_ack(794_000); + assert_eq!(c.host_cap_kbps, Some(794_000)); + assert_eq!( + c.cap_reprobe_after, + (before * 2).min(CAP_REPROBE_WINDOWS_MAX) + ); + } + } + #[test] fn host_encode_latency_rise_backs_off() { // The compute knee: link pristine, client decoder fine — only HOST encode time moves @@ -1593,6 +1926,25 @@ mod tests { assert_eq!(run_clean(&mut c, start, 4, 20), None); } + #[test] + fn a_session_above_the_env_cap_steps_down_to_it_once() { + // PUNKTFUNK_ABR_MAX_MBPS is the only lever an Automatic session gives the operator, and + // it used to bind only ceilings the PROBE taught — so a session that negotiated a rate + // above the cap simply ran above it forever. No congestion signal will ever find that: + // the link is fine, the cap is policy. + let mut c = BitrateController::with_ceiling_cap(100_000, Some(50_000)); + assert_eq!(c.ceiling_kbps, 50_000); + let start = Instant::now(); + assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000)); + // Suppose the host answers HIGHER than asked (its own floor, an encoder minimum): that + // is the host saying it cannot go there. Don't re-ask every cooldown forever. + c.on_ack(80_000); + assert_eq!(run_clean(&mut c, start, 2, 20), None); + // A ceiling that MOVES is a new question, and gets asked once more. + c.set_ceiling(90_000); // clamped to the 50 Mbps cap → still 50 000, no new ask + assert_eq!(run_clean(&mut c, start, 24, 20), None); + } + #[test] fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() { // The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated) @@ -1650,7 +2002,7 @@ mod tests { ), Some(350_000) ); - assert_eq!(c.decode_cap_kbps, Some(500_000)); + assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); // The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps // link ceiling the old sawtooth kept re-poking. c.on_ack(350_000); @@ -1667,14 +2019,22 @@ mod tests { false, 0, ) { - assert!(k <= 500_000, "climb past the decode cap: {k}"); + // Never past the cap in force when the decision was made. (A long clean run + // legitimately re-probes that cap upward — `decode_cap_reprobes_after_a_ + // sustained_clean_run` owns that; here the point is that nothing climbs toward + // the 900 Mbps LINK ceiling the old sawtooth kept re-poking.) + assert!( + k <= c.decode_cap_kbps.unwrap(), + "climb past the decode cap: {k}" + ); max_req = max_req.max(k); c.on_ack(k); } } - assert_eq!(max_req, 500_000); - assert_eq!(c.current_kbps, 500_000); - assert_eq!(c.decode_cap_kbps, Some(500_000)); + assert!( + max_req < 600_000, + "the decode knee stopped binding: climbed to {max_req}" + ); } #[test] @@ -1748,10 +2108,10 @@ mod tests { 0, ); } - assert_eq!(c.decode_cap_kbps, Some(500_000)); + assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); // The host's ack parks the session at the knee (its clamp is authoritative). - c.on_ack(500_000); - for i in 0..CAP_REPROBE_WINDOWS { + c.on_ack(500_000 - 500_000 / 16); + for i in 0..CAP_REPROBE_WINDOWS_MIN { let _ = c.on_window( ticks(start, 8 + i), 0, @@ -1764,7 +2124,8 @@ mod tests { 0, ); } - assert_eq!(c.decode_cap_kbps, Some(500_000 + 500_000 / 8)); + let knee = 500_000 - 500_000 / 16; + assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8)); } #[test] @@ -1800,7 +2161,7 @@ mod tests { 0, ); } - assert_eq!(c.decode_cap_kbps, Some(500_000)); + assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); c.on_mode_switch(); assert!(c.decode_cap_kbps.is_none()); assert_eq!(c.ceiling_kbps, 900_000); diff --git a/crates/punktfunk-core/src/client/pump/data.rs b/crates/punktfunk-core/src/client/pump/data.rs index a83339b8..95b2c4e7 100644 --- a/crates/punktfunk-core/src/client/pump/data.rs +++ b/crates/punktfunk-core/src/client/pump/data.rs @@ -232,7 +232,7 @@ impl DataPump { last_late = st.fec_late_shards; last_received = st.packets_received; last_dropped = st.frames_dropped; - last_bytes = st.bytes_received; + last_bytes = st.media_bytes_received; last_report = Instant::now(); discard_abr_window = true; flush_in_window = false; @@ -317,11 +317,12 @@ impl DataPump { "adaptive bitrate: capacity probe declined — keeping negotiated ceiling" ); } - // The probe's FLAG_PROBE filler landed in `bytes_received` but never reached - // the decoder — rebase the ABR window's byte counter past it, or the next - // window's "actual throughput" reads as the burst rate and poisons the - // controller's proven-throughput high-water mark with the LINK rate. - last_bytes = st.bytes_received; + // Rebase the ABR window's byte anchor past the burst. (Probe filler is + // routed out of `media_bytes_received` at the reassembler, so it can no + // longer read as the burst rate on its own — but the anchor still has to + // skip the video that landed around the burst under a suppressed report + // tick, which would otherwise divide a long span's bytes by one window.) + last_bytes = st.media_bytes_received; } else if Instant::now() >= deadline { // The host never answered (a build that ignores ProbeRequest): clear the // stuck-active state so LossReports resume, keep the negotiated ceiling. @@ -454,11 +455,17 @@ impl DataPump { // the next one. let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed); // The window's ACTUAL delivered throughput — what the pipeline really carried, vs - // the target it was allowed. Wire bytes (headers + FEC) slightly overstate the - // media rate the decoder ingests; acceptable for the climb gate / proven-mark - // semantics (both compare against targets with their own headroom). + // the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC + // parity, no probe filler, no audio), because both consumers compare it against + // the ENCODER's target: the utilization gate asks "was the target genuinely + // tested?" and the proven mark bounds every later climb. Wire bytes answered a + // different question — they rise with the redundancy the host adds in answer to + // loss, so the gate read ~25 % high precisely on the links it exists for. let window_ms = last_report.elapsed().as_millis().max(1) as u64; - let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8) + let actual_kbps = (st + .media_bytes_received + .wrapping_sub(last_bytes) + .saturating_mul(8) / window_ms) as u32; // A discard window feeds the controller NOTHING — its signals are probe-tail // residue, and one "congestion" verdict here ends slow start for good. @@ -492,7 +499,15 @@ impl DataPump { recovery_kf = recovery_kf_reqs, "adaptive bitrate: requesting encoder re-target" ); - let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)); + if ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)).is_err() { + // Never reached the control task — tell the controller, or three of + // these retire it for the session as "the host never acked". + abr.on_request_dropped(); + tracing::warn!( + kbps, + "adaptive bitrate: control queue full — re-target dropped" + ); + } } flush_in_window = false; last_report = Instant::now(); @@ -500,7 +515,7 @@ impl DataPump { last_late = st.fec_late_shards; last_received = st.packets_received; last_dropped = st.frames_dropped; - last_bytes = st.bytes_received; + last_bytes = st.media_bytes_received; if pump_perf_on { if let Some(p) = session.take_pump_perf() { let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0); diff --git a/crates/punktfunk-core/src/packet/reassemble.rs b/crates/punktfunk-core/src/packet/reassemble.rs index 1bc7e6a2..5e9d595a 100644 --- a/crates/punktfunk-core/src/packet/reassemble.rs +++ b/crates/punktfunk-core/src/packet/reassemble.rs @@ -429,6 +429,13 @@ impl Reassembler { stats .probe_last_arrival_ns .store(now_ns, std::sync::atomic::Ordering::Relaxed); + } else if hdr.shard_index < hdr.data_shards { + // Media accounting (see `Stats::media_bytes_received`): DATA shards only, payload + // only. Stamped at the same routing decision as the probe counters and for the same + // reason — the adaptive-bitrate utilization gate compares delivered throughput + // against an ENCODER target, so parity, headers and probe filler have no business + // in the numerator. + StatsCounters::add(&stats.media_bytes_received, shard_bytes as u64); } let win = if is_probe { probe } else { video }; win.advance_window( diff --git a/crates/punktfunk-core/src/stats.rs b/crates/punktfunk-core/src/stats.rs index d293afd5..bfd4f026 100644 --- a/crates/punktfunk-core/src/stats.rs +++ b/crates/punktfunk-core/src/stats.rs @@ -45,6 +45,16 @@ pub struct Stats { /// so a speed-test numerator built from it inherits whatever video was in flight around /// the burst — these keep video out of the probe math. Deliberately NOT mirrored into the /// C-ABI `PunktfunkStats` (probe measurements surface via `ProbeOutcome`). + /// Media bytes delivered to the video reassembler: DATA-shard payload only — no packet + /// headers, no FEC parity, no probe filler, no audio. This is the rate the encoder's target + /// is a promise about, and the only honest thing to compare that target against. + /// `bytes_received` counts every accepted datagram, so a "delivered throughput" built from + /// it rises with the FEC redundancy the host adds in answer to loss — which meant the + /// adaptive-bitrate utilization gate ("did the pipeline actually carry ~the target?") read + /// 25 % high exactly on the lossy links it exists for, and the never-decaying + /// proven-throughput mark inherited the same inflation. Deliberately NOT mirrored into the + /// C-ABI `PunktfunkStats`. + pub media_bytes_received: u64, pub probe_packets_received: u64, pub probe_bytes_received: u64, /// First / last probe-packet arrival (monotonic ns, see [`now_monotonic_ns`]; 0 = none @@ -75,6 +85,7 @@ pub struct StatsCounters { pub fec_late_shards: AtomicU64, pub bytes_sent: AtomicU64, pub bytes_received: AtomicU64, + pub media_bytes_received: AtomicU64, pub probe_packets_received: AtomicU64, pub probe_bytes_received: AtomicU64, pub probe_first_arrival_ns: AtomicU64, @@ -101,6 +112,7 @@ impl StatsCounters { fec_late_shards: self.fec_late_shards.load(l), bytes_sent: self.bytes_sent.load(l), bytes_received: self.bytes_received.load(l), + media_bytes_received: self.media_bytes_received.load(l), probe_packets_received: self.probe_packets_received.load(l), probe_bytes_received: self.probe_bytes_received.load(l), probe_first_arrival_ns: self.probe_first_arrival_ns.load(l), diff --git a/crates/punktfunk-host/src/log_capture.rs b/crates/punktfunk-host/src/log_capture.rs index f0e489f2..68edbf52 100644 --- a/crates/punktfunk-host/src/log_capture.rs +++ b/crates/punktfunk-host/src/log_capture.rs @@ -77,6 +77,32 @@ impl LogRing { .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); + self.push_entry(level.to_string(), target.to_string(), msg, ts_ms); + } + + /// Ingest a line that was produced in **another process** — the plugin/script runner, via + /// `POST /plugins/logs` (see `mgmt::plugins::ingest_plugin_logs`). + /// + /// Plugins are not host child processes: the runner is a separate bun process that `import()`s + /// each plugin in-process, so a plugin's output never passes through this process's `tracing` + /// and [`RingLayer`] can't see it. Without this door the console's log page shows nothing about + /// the plugins at all, and on Windows nothing else does either — the runner task writes no log + /// file, so a failing plugin was diagnosable only by stopping the task and re-running it by + /// hand (field report 2026-08-03, the VirtualHere plugin). + /// + /// The caller's `ts_ms` is kept — the line was stamped when it happened, and re-stamping it on + /// arrival would collapse a whole batch onto the moment it was flushed. `seq` stays ours: it is + /// the cursor for a single ring with several producers, so only the ring can mint it. + pub fn push_remote(&self, level: &str, target: &str, msg: &str, ts_ms: u64) { + self.push_entry( + normalize_level(level).to_string(), + target.to_string(), + truncate_msg(msg.to_string()), + ts_ms, + ); + } + + fn push_entry(&self, level: String, target: String, msg: String, ts_ms: u64) { let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let seq = inner.next_seq; inner.next_seq += 1; @@ -86,8 +112,8 @@ impl LogRing { inner.entries.push_back(LogEntry { seq, ts_ms, - level: level.to_string(), - target: target.to_string(), + level, + target, msg, }); } @@ -125,6 +151,33 @@ pub fn ring() -> &'static LogRing { RING.get_or_init(LogRing::new) } +/// Coerce an externally-supplied level to the five the console's filter ranks. Anything else — +/// a plugin inventing `NOTICE`, a truncated line, empty — becomes `INFO` rather than being +/// rejected: an unfamiliar level is not a reason to drop the operator's diagnostics on the floor, +/// and an unranked string would sort as `0` in the console's `RANK` map and hide under every filter. +fn normalize_level(level: &str) -> &'static str { + match level.trim().to_ascii_uppercase().as_str() { + "ERROR" | "FATAL" | "SEVERE" => "ERROR", + "WARN" | "WARNING" => "WARN", + "DEBUG" => "DEBUG", + "TRACE" | "VERBOSE" => "TRACE", + _ => "INFO", + } +} + +/// Cap a message at [`MAX_MSG`], cutting on a char boundary and marking the elision. +fn truncate_msg(mut msg: String) -> String { + if msg.len() > MAX_MSG { + let mut end = MAX_MSG; + while !msg.is_char_boundary(end) { + end -= 1; + } + msg.truncate(end); + msg.push('…'); + } + msg +} + /// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict /// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty /// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs @@ -223,15 +276,7 @@ impl FieldFmt { } else { self.msg.push_str(&self.fields); } - if self.msg.len() > MAX_MSG { - let mut end = MAX_MSG; - while !self.msg.is_char_boundary(end) { - end -= 1; - } - self.msg.truncate(end); - self.msg.push('…'); - } - self.msg + truncate_msg(self.msg) } } @@ -360,6 +405,45 @@ mod tests { assert!(page.entries.iter().any(|e| e.target == "mdns_sdx")); } + #[test] + fn remote_entries_keep_their_own_timestamp_and_share_the_cursor() { + let ring = LogRing::new(); + ring.push(&tracing::Level::INFO, "punktfunk_host", "local".into()); + ring.push_remote("WARN", "plugin:virtualhere", "remote", 1_700_000_000_123); + + let page = ring.since(0, 10); + assert_eq!(page.entries.len(), 2); + // One sequence across both producers — the console's cursor cannot see two rings. + assert_eq!(page.entries[0].seq, 1); + assert_eq!(page.entries[1].seq, 2); + let remote = &page.entries[1]; + assert_eq!(remote.level, "WARN"); + assert_eq!(remote.target, "plugin:virtualhere"); + assert_eq!(remote.msg, "remote"); + // Stamped when it happened, not when the batch arrived. + assert_eq!(remote.ts_ms, 1_700_000_000_123); + } + + #[test] + fn remote_levels_are_coerced_not_rejected() { + assert_eq!(normalize_level("error"), "ERROR"); + assert_eq!(normalize_level(" Warning "), "WARN"); + assert_eq!(normalize_level("TRACE"), "TRACE"); + // An unranked level would sort as 0 in the console's filter and hide under every setting. + assert_eq!(normalize_level("NOTICE"), "INFO"); + assert_eq!(normalize_level(""), "INFO"); + } + + #[test] + fn remote_messages_are_truncated_like_local_ones() { + let ring = LogRing::new(); + ring.push_remote("INFO", "plugin:x", &"ä".repeat(MAX_MSG), 1); + let page = ring.since(0, 10); + let msg = &page.entries[0].msg; + assert!(msg.ends_with('…')); + assert!(msg.len() <= MAX_MSG + '…'.len_utf8()); + } + #[test] fn message_truncation_keeps_char_boundary() { let f = FieldFmt { diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 08cc6844..a1dbf8a3 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -253,6 +253,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(plugins::list_plugins)) .routes(routes!(plugins::register_plugin, plugins::delete_plugin)) .routes(routes!(plugins::get_ui_credential)) + .routes(routes!(plugins::ingest_plugin_logs)) .routes(routes!(store::get_catalog)) .routes(routes!(store::refresh_catalog)) .routes(routes!(store::list_installed)) diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs index 992e857e..c57ab88f 100644 --- a/crates/punktfunk-host/src/mgmt/plugins.rs +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -29,6 +29,12 @@ use std::time::{Duration, Instant}; /// this tolerates two missed ticks before a plugin drops out of the listing. const LEASE_TTL: Duration = Duration::from_secs(90); +/// Lines accepted per `POST /plugins/logs`. The runner batches on a short timer, so a batch this +/// size means a plugin is logging faster than the ring can usefully hold — the shipper drops its +/// own backlog (and says so in a line of its own) rather than letting one chatty plugin evict the +/// whole ring in a single request. +const MAX_LOG_BATCH: usize = 256; + // ---------------------------------------------------------------- wire shapes /// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request @@ -60,6 +66,26 @@ pub(crate) struct PluginRegistration { pub ui: Option, } +/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`). +#[derive(Deserialize, ToSchema)] +pub(crate) struct PluginLogLine { + /// When the line was produced, unix milliseconds. Kept verbatim — see + /// [`crate::log_capture::LogRing::push_remote`]. + pub ts_ms: u64, + /// `ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`. + pub level: String, + /// Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`. + /// Surfaced in the console's target column as `plugin:`. + pub source: String, + pub msg: String, +} + +/// A batch of runner log lines. +#[derive(Deserialize, ToSchema)] +pub(crate) struct PluginLogBatch { + pub entries: Vec, +} + /// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser. #[derive(Serialize, ToSchema)] pub(crate) struct PluginUiPublic { @@ -264,6 +290,26 @@ fn sanitize(s: &str) -> String { .to_string() } +/// The console target for a runner-supplied line: `plugin:`. +/// +/// The source is NOT a [`valid_plugin_id`] — the runner names a unit by its `definePlugin` name +/// (`virtualhere`), its package name (`@punktfunk/plugin-virtualhere`), a bare script's file stem, +/// or `runner` for its own supervision lines, and all four are worth telling apart in the log. So +/// this sanitizes rather than validates: control characters go (a log target is rendered in a +/// terminal by `logs download` as readily as in the console), length is capped, and an empty source +/// becomes `runner` so a line is never attributed to nothing. +fn log_target(source: &str) -> String { + let mut s = sanitize(source); + if s.is_empty() { + s = "runner".into(); + } + // Cap on CHARS, not bytes — truncating a multi-byte name mid-sequence would panic. + if s.chars().count() > 64 { + s = s.chars().take(64).collect(); + } + format!("plugin:{s}") +} + /// Validate a registration body into the internal [`Valid`] form, or a human-readable reason. fn validate(reg: PluginRegistration) -> Result { let title = sanitize(®.title); @@ -367,6 +413,63 @@ pub(crate) async fn register_plugin( StatusCode::NO_CONTENT.into_response() } +/// Ingest runner log lines +/// +/// The plugin/script runner ships its output here so the console's **Logs** page can show it. +/// +/// Plugins are not host child processes — the runner is a separate `bun` process that `import()`s +/// each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and +/// before this endpoint the console's log page could not show a single plugin line. On Linux the +/// fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no +/// log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and +/// re-running the runner by hand. Both are shell access on the host box, which is exactly what the +/// console exists to avoid. +/// +/// Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted +/// `plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll. +#[utoipa::path( + post, + path = "/plugins/logs", + tag = "plugins", + operation_id = "ingestPluginLogs", + request_body = PluginLogBatch, + responses( + (status = NO_CONTENT, description = "Lines ingested"), + (status = BAD_REQUEST, description = "Batch too large", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn ingest_plugin_logs(ApiJson(batch): ApiJson) -> Response { + if batch.entries.len() > MAX_LOG_BATCH { + return api_error( + StatusCode::BAD_REQUEST, + &format!("at most {MAX_LOG_BATCH} entries per batch"), + ); + } + for line in batch.entries { + crate::log_capture::ring().push_remote( + &line.level, + &log_target(&line.source), + &sanitize_msg(&line.msg), + line.ts_ms, + ); + } + StatusCode::NO_CONTENT.into_response() +} + +/// Strip control characters from an ingested message, keeping tabs. +/// +/// Same reasoning as [`sanitize`], one exception wider: a plugin's messages routinely carry a +/// stack trace or a vendor CLI's output, and an embedded newline would let one line forge several +/// in the downloaded log file. Tabs survive because they are load-bearing in that kind of output. +fn sanitize_msg(s: &str) -> String { + s.chars() + .map(|c| if c == '\t' || !c.is_control() { c } else { ' ' }) + .collect::() + .trim_end() + .to_string() +} + /// List registered plugins /// /// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 256774fe..28c24881 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -620,6 +620,23 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() { StatusCode::NO_CONTENT ); + // Log ingest. This is the ONLY token the scripting runner holds (on Windows its LocalService + // principal cannot even read the admin one), so if this lane ever stopped reaching this route + // the console's plugin logs would go quiet with nothing else failing — pin it here rather than + // rely on `plugin_may_access`'s denylist continuing to not match `/plugins/logs`. + let body = serde_json::json!({"entries": [{ + "ts_ms": 1_700_000_000_000u64, + "level": "INFO", + "source": "virtualhere", + "msg": "hello from the runner", + }]}); + let req = axum::http::Request::post("/api/v1/plugins/logs") + .header("content-type", "application/json") + .header("authorization", "Bearer plugin-secret") + .body(Body::from(body.to_string())) + .unwrap(); + assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT); + // The carve-outs answer 403 (authenticated but not authorized), not 401. for (method, path) in [ (Method::GET, "/api/v1/hooks"), @@ -972,6 +989,59 @@ async fn plugin_registry_roundtrip() { assert_eq!(status, StatusCode::BAD_REQUEST); } +/// Runner log ingest: lines reach the same ring `GET /logs` serves, tagged so the console can tell +/// them from the host's own, and one chatty plugin can't evict the ring in a single request. +#[tokio::test] +async fn plugin_log_ingest_lands_in_the_ring() { + let app = test_app(test_state(), None); + let marker = "vh-ingest-marker-3f9a"; + + let (status, _) = send( + &app, + post_json( + "/api/v1/plugins/logs", + serde_json::json!({"entries": [ + {"ts_ms": 1_700_000_000_123u64, "level": "warn", "source": "virtualhere", "msg": marker}, + // No source: attributed to the runner rather than to nothing. + {"ts_ms": 1_700_000_000_124u64, "level": "NOTICE", "source": "", "msg": "orphan"}, + ]}), + ), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let (status, body) = send(&app, get_req("/api/v1/logs?limit=1000")).await; + assert_eq!(status, StatusCode::OK); + let entries = body["entries"].as_array().unwrap(); + + let mine = entries + .iter() + .find(|e| e["msg"] == marker) + .expect("ingested line is served by GET /logs"); + // `plugin:` is what the console's Host/Plugins filter keys on. + assert_eq!(mine["target"], "plugin:virtualhere"); + // Lowercase in, canonical out — the console ranks these five and nothing else. + assert_eq!(mine["level"], "WARN"); + // Stamped when the line happened, not when the batch arrived. + assert_eq!(mine["ts_ms"], 1_700_000_000_123u64); + + let orphan = entries.iter().find(|e| e["msg"] == "orphan").unwrap(); + assert_eq!(orphan["target"], "plugin:runner"); + // An unranked level would sort as 0 in the console's filter and hide under every setting. + assert_eq!(orphan["level"], "INFO"); + + // An oversized batch is refused whole rather than half-ingested. + let big: Vec = (0..300) + .map(|i| serde_json::json!({"ts_ms": 1u64, "level": "INFO", "source": "x", "msg": format!("f{i}")})) + .collect(); + let (status, _) = send( + &app, + post_json("/api/v1/plugins/logs", serde_json::json!({"entries": big})), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + /// The OpenAPI document lists every route with a unique operationId (codegen relies /// on both), and the checked-in copy is current. #[test] diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 7dad3025..f2cafd6a 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1073,6 +1073,17 @@ async fn serve_session( // accepted ack as "the active mode is now X" and fixes itself; old clients just log it. let (reconfig_result_tx, reconfig_result_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Unsolicited bitrate re-target, data plane → control task (the `reconfig_result_tx` pattern + // again, for the same reason). A pipeline rebuild can RE-RESOLVE an Automatic rate — most + // visibly when the source delivers a different size than the session negotiated, e.g. a + // client that asked for 1080p mirroring a 4K panel — and that number is what everything + // downstream reasons about: the send pacer, the console, and the base a `SetBitrate` ack is + // measured against. The client's copy only ever moved on an ack, so it stayed on the + // negotiated rate while the host encoded at another one, and the ABR's first climb computed + // from that stale base asked for LESS than the host was already sending — a re-target + // downward, with the rebuild it costs. Tell the client instead; `BitrateChanged` already + // means exactly this and old clients already handle one arriving unprompted. + let (retarget_tx, retarget_rx) = tokio::sync::mpsc::unbounded_channel::(); // Cursor-forward bridge (M2): the encode loop diffs each frame's cursor serial and hands // changed SHAPES here; the control task (the control stream's sole writer) sends them. // Same shape as `probe_result_tx`. Wired even when the channel wasn't negotiated — it @@ -1133,6 +1144,7 @@ async fn serve_session( probe_tx, probe_result_rx, reconfig_result_rx, + retarget_rx, cursor_shape_rx, cursor_client_draws, clip_enabled, @@ -1579,6 +1591,7 @@ async fn serve_session( probe_rx, probe_result_tx, reconfig_result_tx, + retarget_tx, fec_target: fec_target_dp, phase: phase_ctl, conn: conn_stream, diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index e8b89675..e14934c6 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -40,6 +40,9 @@ pub(super) async fn run( probe_tx: std::sync::mpsc::Sender, mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver, mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver, + // Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to + // the client as a `BitrateChanged` so its controller's climb base tracks the real encoder. + mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver, mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver, cursor_client_draws: Arc, clip_enabled: Arc, @@ -338,6 +341,27 @@ pub(super) async fn run( None => clip_offer_closed = true, } } + retarget = retarget_rx.recv() => { + // A pipeline rebuild re-resolved the Automatic rate (see `retarget_tx`). Same + // message the `SetBitrate` path answers with — the client's controller treats + // any `BitrateChanged` as authoritative for what the encoder now targets, which + // is exactly right here: it IS what the encoder now targets, we just weren't + // asked. PyroWave reaches this too, and should: its rate is pinned against + // mid-stream RETARGETS, but a mode switch legitimately re-resolves the pin + // (~1.6 bpp for the new pixel rate) and the client's live-rate display is + // otherwise stuck on the old one. Its controller is off, so nothing acts on it. + let Some(kbps) = retarget else { break }; // data plane gone + tracing::info!( + kbps, + "encoder re-targeted by a pipeline rebuild — telling the client" + ); + if io::write_msg(&mut ctrl_send, &BitrateChanged { bitrate_kbps: kbps }.encode()) + .await + .is_err() + { + break; + } + } correction = reconfig_result_rx.recv() => { // H2 rollback/correction ack: the data plane reports the mode ACTUALLY live // after a rebuild that failed (stayed at the old mode) or that the backend diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 0c263638..6bba243e 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1214,6 +1214,9 @@ pub(super) struct SessionContext { /// `Reconfigured { accepted: true, mode: }` when a rebuild failed (stayed at /// the old mode) or the backend honored a different refresh than requested. pub(super) reconfig_result_tx: tokio::sync::mpsc::UnboundedSender, + /// Host-initiated bitrate re-target → control task → the client's `BitrateChanged`. Fired + /// by [`adopt_built_bitrate`] when a rebuild lands on a rate the client wasn't told about. + pub(super) retarget_tx: tokio::sync::mpsc::UnboundedSender, /// Adaptive-FEC target the control task updates from the client's loss reports. pub(super) fec_target: Arc, /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). @@ -1397,6 +1400,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option 1 || pipelined_active || deescalating; // Export "encode can't hold cadence" for the control task's climb refusal. - // An escalated session stays flagged even with the bucket drained: its climb - // headroom is spent, and letting climbs resume would saw against the + // An escalated session is held to a stricter standard — ANY net behind-frame + // keeps it flagged, where an unescalated one is given the full bucket — because + // its climb headroom really is partly spent and a climb would saw against the // escalation and starve the de-escalation clean run below. + // + // But being escalated cannot flag it BY ITSELF, which is what this used to do. + // The client can't tell a transient refusal from an encoder's real ceiling: two + // identical short acks latch a cap, so a session that escalated once — the + // bucket needs ~20 net misses, which a startup hitch supplies while the ABR is + // still in slow start at the 20 Mbps default — got pinned there, and stayed + // pinned long after the escalation had bought back the headroom it was for. + // Escalating exists precisely so cadence CAN be held; once it is (bucket + // drained, every frame on time), refusing climbs is refusing the thing that + // worked. cadence_degraded.store( - escalated || behind_score >= DEPTH_DEGRADE, + encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE), Ordering::Relaxed, ); if deescalating { @@ -4068,13 +4106,42 @@ impl PaceBudget { } } +/// Does the encoder currently fail to hold the frame cadence? Exported to the control task, which +/// refuses bitrate CLIMBS while it is true (descents always pass — they are the cure). +/// +/// `escalated` = the session has already spent an adaptive-depth / pipelined-retrieve step to buy +/// headroom; `behind_score` is the leaky bucket of frames whose work overran the cadence deadline. +/// An escalated session is judged strictly — ANY net behind-frame keeps it flagged — but being +/// escalated does not flag it on its own. That distinction is the whole point: the client cannot +/// tell a transient refusal from an encoder's hard ceiling (two identical short acks latch a cap), +/// so "escalated ⇒ degraded, permanently" pinned Automatic sessions at whatever rate they happened +/// to hold when a startup hitch escalated them — routinely the 20 Mbps default, while slow start +/// had barely begun. Escalation exists so cadence CAN be held; once it is, refusing climbs refuses +/// the thing that worked. +fn encode_behind_cadence(escalated: bool, behind_score: u32, degrade_at: u32) -> bool { + behind_score >= degrade_at || (escalated && behind_score > 0) +} + /// Adopt the rate a freshly built pipeline's encoder was actually opened at. /// /// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's /// climb base, the console's sample, what a `SetBitrate` ack is measured against — so letting it /// disagree with the live encoder means each of those reasons about a stream that doesn't exist. /// Silent when nothing changed, which is the overwhelmingly common case. -fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc) { +/// +/// The client keeps its OWN copy of that number, and it used to move only on an ack — so a +/// rebuild that re-resolved an Automatic rate (`build_pipeline` does, whenever the source +/// delivers a size the session did not negotiate) left the two disagreeing for the rest of the +/// session. The ABR's next climb then computed from the stale base and asked for a rate BELOW +/// what the host was already sending: a re-target downward, paying an encoder rebuild to get +/// there. So tell the client too — `BitrateChanged` is the same message the `SetBitrate` path +/// answers with, and means the same thing arriving unprompted. +fn adopt_built_bitrate( + current: &mut u32, + built: u32, + live: &Arc, + retarget: &tokio::sync::mpsc::UnboundedSender, +) { if built == *current { return; } @@ -4085,6 +4152,7 @@ fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc) { ); *current = built; live.store(built, Ordering::Relaxed); + let _ = retarget.send(built); // control task gone ⇒ the session is ending anyway } /// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and @@ -4329,6 +4397,38 @@ fn build_pipeline( mod tests { use super::*; + #[test] + fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() { + const DEGRADE: u32 = 10; + // Not escalated: the full bucket is allowed before climbs are refused. + assert!(!encode_behind_cadence(false, 0, DEGRADE)); + assert!(!encode_behind_cadence(false, 9, DEGRADE)); + assert!(encode_behind_cadence(false, 10, DEGRADE)); + // Escalated and still missing deadlines: strict — one net behind-frame is enough. + assert!(encode_behind_cadence(true, 1, DEGRADE)); + // Escalated, bucket fully drained: cadence is being HELD, which is what escalating was + // for. This is the case that used to stay latched for the rest of the session and pin an + // Automatic client at its slow-start rate. + assert!(!encode_behind_cadence(true, 0, DEGRADE)); + } + + #[test] + fn adopting_a_rebuilt_rate_tells_the_client() { + let live = Arc::new(AtomicU32::new(20_000)); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut current = 20_000; + // The overwhelmingly common case: the rebuild landed on the same rate — silent. + adopt_built_bitrate(&mut current, 20_000, &live, &tx); + assert_eq!(rx.try_recv().ok(), None); + // A re-resolve (the client asked 1080p, the source delivers a mirrored 4K panel): the + // host's rate moves, so the client has to hear about it — its controller's climb base is + // its own copy of this number, and a stale one makes the next "climb" a cut. + adopt_built_bitrate(&mut current, 60_000, &live, &tx); + assert_eq!(current, 60_000); + assert_eq!(live.load(Ordering::Relaxed), 60_000); + assert_eq!(rx.try_recv().ok(), Some(60_000)); + } + #[test] fn pacing_never_exceeds_the_session_rate_or_the_display() { // Backend honored the request exactly (the multiplier off): pace at it. diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 6445c987..025e744d 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -15,8 +15,10 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa **Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings* elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the -client's **console home**, whose settings screen is one steppable list; the Decky plugin has a -smaller section of its own. The console home is part of the client — it is not the host's +client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings +tab covers the same store in the same groups and the same order, as a left rail of categories the +way SteamOS's own Settings looks. The console home is part of the +client — it is not the host's [web console](/docs/web-console). Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin @@ -43,8 +45,9 @@ and your client scales what it gets — see **Match window** — *default: off.* The stream mode follows your window instead, and each resize renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen -degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and console -home screens; not by Android or Decky. +degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console +home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are +always fullscreen, so it lands on native); not by Android. **Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display, @@ -69,8 +72,8 @@ link. The stops run 0.5× to 4×. The result is floored to an even size and capp **Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264. -**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an -Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on +**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or +an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the device has a hardware AV1 decoder; Android never offers PyroWave. @@ -83,26 +86,27 @@ Full detail: [HDR](/docs/hdr). needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware -decode probe to pass). Android, Decky and the console home don't offer it. +decode probe to pass). The console home and Decky offer the toggle; Android doesn't. **Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens -those hiccups out, at that buffer's worth of added delay. Linux and Windows apps; the Apple and -Android apps have carried the same setting for a while, and it is stored under the same name, so a -[profile](/docs/profiles-and-links) means the same thing on every device. +those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home +and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored +under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device. **Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How many frames are held back before showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra -delay bought against 17 ms of jitter. If you never see stutter, you don't need this. +delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever +**Prioritize** is offered, and greyed out until you pick Smoothness. **V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every driver or compositor offers a tearing mode, and where none is available the stream stays tear-free. The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off" -from "off but unavailable". Linux and Windows apps. +from "off but unavailable". Linux and Windows apps, the console home and Decky. **Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel refresh in step with the stream rather than on a fixed cadence — which removes the wait between a @@ -111,7 +115,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc graphics driver that offers the modern queue-free display mode; on an older driver it does nothing unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay -reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps. +reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps, +the console home and Decky. **Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual output. Advisory: a host without that backend quietly auto-detects instead. @@ -123,7 +128,7 @@ stereo. The count the host will really send comes back in the handshake, and you decoder from *that*, never from the request. What surround means differs by host: a **Linux** host claims a sink advertising exactly that many channels, so applications produce real surround, while a **Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1 -from a stereo endpoint is an upmix, not new channels. Offered everywhere except the Decky plugin. +from a stereo endpoint is an upmix, not new channels. Offered everywhere. **Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the @@ -137,16 +142,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind for the Communications stream category so the endpoint's processing engages, and on **Apple** and **Android** the platform's voice-processing mode. Turn it off if your microphone already runs its own processing, or if the canceller makes your voice sound thin. The row sits under the microphone -toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and -console-home clients; Decky has no toggle. What it can and can't fix is in +toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android, +console-home and Decky clients. What it can and can't fix is in [Why do I hear myself](/docs/echo). **Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream -audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the -**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV, -Android, Decky and the console home have none, and the Windows app has none and ignores a stored +audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the +**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad, +Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather -than silently snapping back to the default; the Mac shows it as "Unavailable device". +than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as +"(not connected)". Decky reads the endpoint list from the client's session binary, so a client +older than the two-binary split leaves these pickers on Automatic. ## Input @@ -186,8 +193,10 @@ which forwards *every* connected controller, each as its own player, on Linux, W console home. Pinning one restricts the session to that controller alone — single-player. The Android app has no such picker. -**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps only; Windows -spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On, Alt+Tab and the Windows key +**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home +and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it +matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming +Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key (Super on Linux) reach the host while the stream has input captured. Off, they act on this machine instead — what you want when the stream shares a screen with local work. Either way the chords come back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream @@ -206,17 +215,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this -toggle. The console home has no toggle — it offers wake as an explicit action on an offline host -instead — and the Decky plugin always sends a wake before a stream starts. See +toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own +before a stream starts whatever this setting says, so on a Deck it governs the client's connect +rather than the launch. The console home also offers wake as an explicit action on an offline host. +See [Wake-on-LAN](/docs/wake-on-lan). **Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse -a paired host's games and launch one directly; the Windows app still labels it experimental. There is -no toggle in the console home or in Decky. See [Game library](/docs/game-library). +a paired host's games and launch one directly; the Windows app still labels it experimental. The +console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the +plugin's own library browser works either way. See [Game library](/docs/game-library). **Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back -when you return to the host list. iPhone, iPad, Apple TV and Android have no equivalent. +when you return to the host list. The console home and Decky carry the row for the desktop client +that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV +and Android have no equivalent. ## Overlay @@ -224,7 +238,8 @@ when you return to the host list. iPhone, iPad, Apple TV and Android have no equ superset of the one before. This setting only picks the tier a session *starts* at — you can cycle them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky -plugin has no stats setting. The shortcuts, and every number in the overlay, are in +plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the +overlay, are in [Understanding the stats overlay](/docs/stats). ## Settings that are facts about your device @@ -236,8 +251,9 @@ stay global and **cannot be put in a settings profile**: vendor-ordered and falls back on its own; change it only when debugging, and note that `PUNKTFUNK_DECODER` overrides it ([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux, - Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has - more than one adapter. The Apple and Android apps have neither. + Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only + when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The + Apple and Android apps have neither. - **Speaker** and **Microphone** device pickers — this device's audio endpoints. - **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx index 567fcd99..9de18f07 100644 --- a/docs-site/content/docs/plugins.mdx +++ b/docs-site/content/docs/plugins.mdx @@ -295,8 +295,23 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa On SteamOS, re-run `scripts/steamdeck/install.sh` (or `scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component. -**The plugin doesn't show up in the console** — check the runner is actually running with -`punktfunk-host plugins status`, then look at its log: +**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter. +The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the +host's, on one timeline, with the same search and download. Each is tagged `plugin:` — the +plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a +plugin, restarting a crashed one, refusing an unsafe file). + +An empty Plugins view almost always means the runner isn't running — it is a separate service, and +opt-in on Linux. Check with `punktfunk-host plugins status`. + + +Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host +comes back. It says so in the log if the buffer overflowed, rather than presenting a gap as +continuity. + + +**Reading the runner's log directly** — rarely needed now, but it is the ground truth if the runner +can't reach the host at all: @@ -318,6 +333,22 @@ plugins (stop it with Ctrl+C): +**A plugin can't reach a service running on the same box (Linux)** — plugins that drive a local +daemon usually talk to it over a socket or FIFO in `/tmp`. The runner's unit shipped with +`PrivateTmp=yes` in earlier releases, which gave it a private `/tmp` and hid all of it: the plugin would +launch the vendor's binary happily and then time out reaching the daemon behind it, while the same +command worked perfectly in your own shell. If you are on an older host, or you have a drop-in that +reinstates it, put the real `/tmp` back: + +```sh +systemctl --user edit punktfunk-scripting +``` +```ini +[Service] +PrivateTmp=no +ReadWritePaths=/tmp +``` + ## Writing your own A plugin is a TypeScript module built on **`@punktfunk/plugin-kit`** (`definePluginKit`), supervised diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index aa16ecd4..18d03cdc 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -360,7 +360,10 @@ Read the host's log around the failed connect or capture. 1. Open the web console's **Logs** page. It always holds the host's recent output at *debug* detail, whatever the log level is set to — there's nothing to switch on and no restart needed. -2. Filter it down to the level or the text you're after. +2. Filter it down to the level or the text you're after. The **Host / Plugins** switch beside the + level buttons picks the producer: your [plugins](/docs/plugins) log to the same page, tagged + `plugin:`, so a misbehaving plugin is one click away rather than a separate hunt through + the journal. 3. Use **Download logs** to save exactly what you're filtering on as a timestamped `.log` file you can attach to a bug report. The button beside it hands the same text to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. diff --git a/docs-site/content/docs/web-console.md b/docs-site/content/docs/web-console.md index cc3ba3a9..c52bd739 100644 --- a/docs-site/content/docs/web-console.md +++ b/docs-site/content/docs/web-console.md @@ -107,8 +107,9 @@ Nine destinations in the sidebar (a **More** tab on a phone holds the last five) title with its own art and launch command. See [Your game library](/docs/game-library). - **Performance** — arm a capture, run a session, stop it, and read the recording back as per-stage latency, throughput and health graphs. -- **Logs** — the host's recent log stream: follow it live, filter by level, search it, and download - or share it for a bug report. +- **Logs** — the host's recent log stream *and your plugins'*: follow it live, filter by level or + producer, search it, and download or share it for a bug report. Plugin lines are tagged + `plugin:` and the **Host / Plugins** switch isolates either side. - **Pairing** — arm a PIN, approve or deny devices waiting for approval, and unpair a device. A second PIN box for [Moonlight/GameStream](/docs/moonlight) clients appears only when this host runs the GameStream plane. diff --git a/scripts/punktfunk-scripting.service b/scripts/punktfunk-scripting.service index 58087f7c..c6bb2dfb 100644 --- a/scripts/punktfunk-scripting.service +++ b/scripts/punktfunk-scripting.service @@ -33,16 +33,24 @@ KillSignal=SIGTERM TimeoutStopSec=30 # Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home # directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable); -# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses -# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME -# (e.g. a library on another mount) gets a drop-in: +# no setuid re-escalation; sockets limited to what automation actually uses (loopback mgmt API, +# LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME (e.g. a library on +# another mount) gets a drop-in: # systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games -# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces -# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +# NOTE: the mount-namespace options (ProtectSystem) need unprivileged user namespaces for a +# *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +# +# PrivateTmp is deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A plugin's +# whole job is integrating with things already running on this box, and on Linux those talk over +# /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + /tmp/vhclient_response, and X11 +# is /tmp/.X11-unix. A private /tmp namespace hides all of it — the plugin launches the vendor +# binary fine and then cannot reach the daemon behind it, which presents as an unexplained error +# that no amount of config fixes (the operator's own shell works, because that has the real /tmp). +# ReadWritePaths=/tmp puts the write bit back that ProtectSystem=strict takes away. NoNewPrivileges=yes -PrivateTmp=yes +PrivateTmp=no ProtectSystem=strict -ReadWritePaths=%h +ReadWritePaths=%h /tmp RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 0b86e27e..f2f65bbd 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -21,6 +21,8 @@ export type ApiGpu = { readonly "id": string, readonly "name": string, readonly export const ApiGpu = Schema.Struct({ "id": Schema.String.annotate({ "description": "Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.\nStable across reboots and driver updates, unlike an adapter index or LUID." }), "name": Schema.String.annotate({ "description": "Adapter/marketing name." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }), "vram_mb": Schema.Number.annotate({ "description": "Dedicated VRAM in MiB (0 where the platform doesn't expose it).", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One hardware GPU on the host (software/WARP adapters are never listed)." }) export type ApiMonitorInfo = { readonly "connector": string, readonly "description": string, readonly "enabled": boolean, readonly "managed": boolean, readonly "mode": string, readonly "primary": boolean, readonly "scale": number, readonly "selected": boolean, readonly "x": number, readonly "y": number } export const ApiMonitorInfo = Schema.Struct({ "connector": Schema.String.annotate({ "description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes." }), "description": Schema.String.annotate({ "description": "Human label for a picker (`make model`, else the connector)." }), "enabled": Schema.Boolean.annotate({ "description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing." }), "managed": Schema.Boolean.annotate({ "description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)." }), "mode": Schema.String.annotate({ "description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)." }), "primary": Schema.Boolean.annotate({ "description": "The compositor's primary/focused head." }), "scale": Schema.Number.annotate({ "description": "Logical scale factor.", "format": "double" }).check(Schema.isFinite()), "selected": Schema.Boolean.annotate({ "description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor." }), "x": Schema.Number.annotate({ "description": "Desktop-space top-left — what makes a head identifiable when two share a size.", "format": "int32" }).check(Schema.isInt()), "y": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()) }).annotate({ "description": "One physical monitor this host has, as the compositor reports it." }) +export type ApplyRequest = { readonly "force"?: boolean } +export const ApplyRequest = Schema.Struct({ "force": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)." })) }) export type ApprovePending = { readonly "name"?: string | null } export const ApprovePending = Schema.Struct({ "name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Operator-chosen label for the device (defaults to the name it knocked with)." })) }).annotate({ "description": "Approve-pending-device request body. Send `{}` to keep the device's own name." }) export type ArmNativePairing = { readonly "fingerprint"?: string | null, readonly "ttl_secs"?: never } @@ -81,6 +83,8 @@ export type PendingDevice = { readonly "age_secs": number, readonly "fingerprint export const PendingDevice = Schema.Struct({ "age_secs": Schema.Number.annotate({ "description": "Seconds since the device last knocked.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "fingerprint": Schema.String.annotate({ "description": "Hex SHA-256 of the device's certificate — what approval pins." }), "id": Schema.Number.annotate({ "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "name": Schema.String.annotate({ "description": "Best-effort device label (the client's own name, else fingerprint-derived)." }) }).annotate({ "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band)." }) export type Plane = "native" | "gamestream" export const Plane = Schema.Literals(["native", "gamestream"]).annotate({ "description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature." }) +export type PluginLogLine = { readonly "level": string, readonly "msg": string, readonly "source": string, readonly "ts_ms": number } +export const PluginLogLine = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." }), "msg": Schema.String, "source": Schema.String.annotate({ "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." }), "ts_ms": Schema.Number.annotate({ "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`)." }) export type PluginRegistration = { readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } export const PluginRegistration = Schema.Struct({ "title": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) export type PluginUiPublic = { readonly "icon"?: string | null, readonly "port": number } @@ -133,6 +137,8 @@ export type UiCredential = { readonly "port": number, readonly "secret": string export const UiCredential = Schema.Struct({ "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String }).annotate({ "description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser." }) export type UninstallRequest = { readonly "pkg": string } export const UninstallRequest = Schema.Struct({ "pkg": Schema.String }) +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 RuntimeStatus = { readonly "active_sessions": number, 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_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 } @@ -155,6 +161,8 @@ export type GameRefPayload = { readonly "app"?: string | null, readonly "client" export const GameRefPayload = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it." })), "client": Schema.String.annotate({ "description": "Client-supplied device name of the session that launched it; may be empty." }), "plane": Plane, "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": "A launched game, as the `game.*` events see it." }) export type StreamRef = { readonly "app"?: string | null, readonly "client": string, readonly "hdr": boolean, readonly "mode": string, readonly "plane": Plane } export const StreamRef = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)." })), "client": Schema.String.annotate({ "description": "Client-supplied device name; may be empty." }), "hdr": Schema.Boolean, "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz`." }), "plane": Plane }).annotate({ "description": "A live video stream (what the stream marker file reflects)." }) +export type PluginLogBatch = { readonly "entries": ReadonlyArray } +export const PluginLogBatch = Schema.Struct({ "entries": Schema.Array(PluginLogLine) }).annotate({ "description": "A batch of runner log lines." }) export type PluginSummary = { readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } export const PluginSummary = Schema.Struct({ "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) export type HostInfo = { readonly "abi_version": number, readonly "app_version": string, readonly "codecs": ReadonlyArray, readonly "gamestream": boolean, readonly "gfe_version": string, readonly "hostname": string, readonly "local_ip": string, readonly "os": string, readonly "os_name": string, readonly "ports": PortMap, readonly "uniqueid": string, readonly "version": string } @@ -175,8 +183,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 "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 "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({ "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({ "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 "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({ "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 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 "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 } @@ -472,6 +480,12 @@ export type ListPlugins200 = ReadonlyArray export const ListPlugins200 = Schema.Array(PluginSummary) export type ListPlugins401 = ApiError export const ListPlugins401 = ApiError +export type IngestPluginLogsRequestJson = PluginLogBatch +export const IngestPluginLogsRequestJson = PluginLogBatch +export type IngestPluginLogs400 = ApiError +export const IngestPluginLogs400 = ApiError +export type IngestPluginLogs401 = ApiError +export const IngestPluginLogs401 = ApiError export type RegisterPluginRequestJson = PluginRegistration export const RegisterPluginRequestJson = PluginRegistration export type RegisterPlugin400 = ApiError @@ -638,6 +652,26 @@ export type UninstallPlugin403 = ApiError export const UninstallPlugin403 = ApiError export type UninstallPlugin409 = ApiError export const UninstallPlugin409 = ApiError +export type ApplyUpdateRequestJson = ApplyRequest +export const ApplyUpdateRequestJson = ApplyRequest +export type ApplyUpdate202 = UpdateStatus +export const ApplyUpdate202 = UpdateStatus +export type ApplyUpdate401 = ApiError +export const ApplyUpdate401 = ApiError +export type ApplyUpdate409 = ApiError +export const ApplyUpdate409 = ApiError +export type ForceUpdateCheck200 = UpdateStatus +export const ForceUpdateCheck200 = UpdateStatus +export type ForceUpdateCheck401 = ApiError +export const ForceUpdateCheck401 = ApiError +export type ForceUpdateCheck409 = ApiError +export const ForceUpdateCheck409 = ApiError +export type ForceUpdateCheck429 = ApiError +export const ForceUpdateCheck429 = ApiError +export type GetUpdateStatus200 = UpdateStatus +export const GetUpdateStatus200 = UpdateStatus +export type GetUpdateStatus401 = ApiError +export const GetUpdateStatus401 = ApiError export interface OperationConfig { /** @@ -1095,6 +1129,15 @@ export const make = ( "401": decodeError("ListPlugins401", ListPlugins401), orElse: unexpectedStatus })) + ), + "ingestPluginLogs": (options) => HttpClientRequest.post(`/api/v1/plugins/logs`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "400": decodeError("IngestPluginLogs400", IngestPluginLogs400), + "401": decodeError("IngestPluginLogs401", IngestPluginLogs401), + "204": () => Effect.void, + orElse: unexpectedStatus + })) ), "registerPlugin": (id, options) => HttpClientRequest.put(`/api/v1/plugins/${id}`).pipe( HttpClientRequest.bodyJsonUnsafe(options.payload), @@ -1321,6 +1364,31 @@ export const make = ( "409": decodeError("UninstallPlugin409", UninstallPlugin409), orElse: unexpectedStatus })) + ), + "applyUpdate": (options) => HttpClientRequest.post(`/api/v1/update/apply`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ApplyUpdate202), + "401": decodeError("ApplyUpdate401", ApplyUpdate401), + "409": decodeError("ApplyUpdate409", ApplyUpdate409), + orElse: unexpectedStatus + })) + ), + "forceUpdateCheck": (options) => HttpClientRequest.post(`/api/v1/update/check`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(ForceUpdateCheck200), + "401": decodeError("ForceUpdateCheck401", ForceUpdateCheck401), + "409": decodeError("ForceUpdateCheck409", ForceUpdateCheck409), + "429": decodeError("ForceUpdateCheck429", ForceUpdateCheck429), + orElse: unexpectedStatus + })) + ), + "getUpdateStatus": (options) => HttpClientRequest.get(`/api/v1/update/status`).pipe( + withResponse(options?.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(GetUpdateStatus200), + "401": decodeError("GetUpdateStatus401", GetUpdateStatus401), + orElse: unexpectedStatus + })) ) } } @@ -1589,6 +1657,21 @@ readonly "submitPairingPin": (options: { readonl */ readonly "listPlugins": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ListPlugins401", typeof ListPlugins401.Type>> /** +* The plugin/script runner ships its output here so the console's **Logs** page can show it. +* +* Plugins are not host child processes — the runner is a separate `bun` process that `import()`s +* each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and +* before this endpoint the console's log page could not show a single plugin line. On Linux the +* fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no +* log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and +* re-running the runner by hand. Both are shell access on the host box, which is exactly what the +* console exists to avoid. +* +* Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted +* `plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll. +*/ +readonly "ingestPluginLogs": (options: { readonly payload: typeof IngestPluginLogsRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"IngestPluginLogs400", typeof IngestPluginLogs400.Type> | PunktfunkError<"IngestPluginLogs401", typeof IngestPluginLogs401.Type>> + /** * Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs * this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console * will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed @@ -1733,6 +1816,24 @@ readonly "deletePluginSource": (name: string, op * the tree. */ readonly "uninstallPlugin": (options: { readonly payload: typeof UninstallPluginRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"UninstallPlugin400", typeof UninstallPlugin400.Type> | PunktfunkError<"UninstallPlugin401", typeof UninstallPlugin401.Type> | PunktfunkError<"UninstallPlugin403", typeof UninstallPlugin403.Type> | PunktfunkError<"UninstallPlugin409", typeof UninstallPlugin409.Type>> + /** +* Starts the one-click apply for install kinds that support it (Windows installer). The +* request carries no version or URL — the host installs exactly what its verified manifest +* announced. Progress is polled via `GET /update/status` (`job`); the host restarts as part +* of the apply, and the outcome lands in `last_result` after it comes back. +*/ +readonly "applyUpdate": (options: { readonly payload: typeof ApplyUpdateRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ApplyUpdate401", typeof ApplyUpdate401.Type> | PunktfunkError<"ApplyUpdate409", typeof ApplyUpdate409.Type>> + /** +* Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to +* one forced check per 30 s. +*/ +readonly "forceUpdateCheck": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ForceUpdateCheck401", typeof ForceUpdateCheck401.Type> | PunktfunkError<"ForceUpdateCheck409", typeof ForceUpdateCheck409.Type> | PunktfunkError<"ForceUpdateCheck429", typeof ForceUpdateCheck429.Type>> + /** +* How this host was installed, which channel it follows, whether a newer release is known, +* and how to update. Reading this may kick a background refresh when the cached check is +* older than 6 h; the response never blocks on the network. +*/ +readonly "getUpdateStatus": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetUpdateStatus401", typeof GetUpdateStatus401.Type>> } export interface PunktfunkError { diff --git a/sdk/src/log-ship.ts b/sdk/src/log-ship.ts new file mode 100644 index 00000000..f7003fca --- /dev/null +++ b/sdk/src/log-ship.ts @@ -0,0 +1,287 @@ +// The runner's log door into the web console (field report 2026-08-03, the VirtualHere plugin). +// +// WHY THIS EXISTS: plugins are not host child processes. The runner is a separate bun process that +// `import()`s each plugin in-process, so a plugin's output is THIS process's stdout and the host's +// `tracing` ring — the thing `GET /api/v1/logs` and the console's Logs page serve — never sees a +// byte of it. On Linux the fallback was `journalctl --user -u punktfunk-scripting`; on Windows the +// runner scheduled task writes no log file AT ALL, so a failing plugin could only be diagnosed by +// stopping the task and re-running the runner by hand. Both need shell access on the host box, +// which is the exact thing the console exists to avoid. A user hitting a plugin misconfiguration +// therefore had no way to see the error explaining it. +// +// So: tee every console line to `POST /api/v1/plugins/logs`, which lands it in the host's ring +// alongside the host's own lines under the target `plugin:`. +// +// Design rules this file will not break: +// - **stdout stays authoritative.** The original console method is called FIRST and always, so +// journald/foreground output is unchanged whatever the host is doing. Shipping is additive. +// - **Never recurse.** Nothing on the shipping path may log through the patched console; a failed +// POST that logged its own failure would enqueue that line, fail again, and spin. +// - **Never throw into a caller.** `console.log` is not allowed to fail because the host is down. +// - **Bounded.** The queue has a hard cap and drops its OLDEST lines, then says how many — an +// unreachable host must not turn the runner into a memory leak. +import { format } from "node:util"; +import { type ConnectOptions, resolveConfig } from "./config.js"; + +/** The level a console method implies when a line carries no level of its own. */ +const LEVEL_BY_METHOD = { + log: "INFO", + info: "INFO", + debug: "DEBUG", + warn: "WARN", + error: "ERROR", +} as const; +type Method = keyof typeof LEVEL_BY_METHOD; +const METHODS = Object.keys(LEVEL_BY_METHOD) as Method[]; + +/** + * ` [] [LEVEL:] ` — the line format `plugin-kit`'s `loggingLayer` and this + * package's `runner.ts` both emit. Parsing it back recovers the plugin name and level that the + * formatting flattened, so the console can show `plugin:virtualhere` / `WARN` instead of one + * undifferentiated `runner` stream. + * + * A line that does NOT match is still shipped — attributed to `runner` at the console method's own + * level. A plugin calling bare `console.error("boom")` is precisely the case this must not lose. + */ +const STAMPED = + /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z) \[([^\]\n]{1,64})\](?:[ \t]+([A-Z]{3,9}):)?[ \t]?([\s\S]*)$/; + +/** Lines per POST. Must stay ≤ the host's `MAX_LOG_BATCH` or a batch is rejected wholesale. */ +const BATCH = 256; +/** Queued lines before the oldest start dropping. ~2 MB worst case at the host's 2 KB cap. */ +const QUEUE_LIMIT = 1000; +/** A multi-line message (a stack trace) ships as one entry per line, capped here. */ +const MAX_LINES_PER_MESSAGE = 40; + +export interface LogShipperOptions { + /** Connection overrides. Defaults to the same zero-config resolution `connect()` uses. */ + connect?: ConnectOptions; + /** Flush cadence in ms (default 2000). */ + intervalMs?: number; +} + +export interface LogShipper { + /** Send whatever is queued right now. Never rejects. */ + flush: () => Promise; + /** Restore the original console methods and stop the timer. */ + stop: () => void; +} + +interface Line { + ts_ms: number; + level: string; + source: string; + msg: string; +} + +/** + * Split a rendered console call into shippable lines. + * + * Newlines become separate entries rather than one blob: the log viewer is line-oriented, and the + * payload that matters most here — an Effect `Cause.pretty` stack from a plugin that failed to + * start — is unreadable folded onto a single row. The cap keeps one pathological dump from + * evicting the ring on its own. + */ +const toLines = (method: Method, args: unknown[]): Line[] => { + const text = format(...args); + const m = STAMPED.exec(text); + const source = m?.[2] ?? "runner"; + const level = m?.[3] ?? LEVEL_BY_METHOD[method]; + const parsedTs = m?.[1] !== undefined ? Date.parse(m[1]) : Number.NaN; + const ts_ms = Number.isNaN(parsedTs) ? Date.now() : parsedTs; + const body = m?.[4] ?? text; + + const parts = body.split(/\r?\n/); + const kept = parts.slice(0, MAX_LINES_PER_MESSAGE); + if (parts.length > kept.length) { + kept.push(`… ${parts.length - kept.length} more line(s) suppressed`); + } + // A trailing newline yields one empty part; an all-empty message still ships one entry so the + // console never silently swallows a call. + const meaningful = kept.filter((l) => l.trim() !== ""); + return (meaningful.length > 0 ? meaningful : [""]).map((msg) => ({ + ts_ms, + level, + source, + msg, + })); +}; + +/** + * Patch the console to tee into the host's log ring, and start the flush timer. + * + * Install this ONLY in the managed runner (`runner-cli.ts`). A plugin's own CLI (`punktfunk-plugin-x + * doctor`) must keep its output local — an operator running a diagnostic in their terminal is not + * asking to write to the host's log. + */ +export const installLogShipper = ( + options: LogShipperOptions = {}, +): LogShipper => { + const queue: Line[] = []; + let droppedByOverflow = 0; + // True from the moment a flush claims a batch until its POST settles. Guards flush RE-ENTRY + // only — the interval can fire while a slow POST is still open, and two concurrent flushes + // would splice disjoint batches out of one queue and deliver them out of order. + // + // It deliberately does NOT gate `enqueue`. It used to, as a recursion guard, and that silently + // dropped every line logged during the few ms of a POST — which under load is a great many of + // them, and exactly the lines a busy plugin is producing. The recursion it was guarding is + // handled by the rule at the top of this file instead: nothing on the shipping path logs. + let shipping = false; + let stopped = false; + // Set once the host's URL/token/CA resolve. Before that (the host writes `plugin-token` as it + // boots, and the runner may well start first) lines keep queueing — those earliest lines are + // exactly the ones that explain a plugin failing to load. + let resolved: Awaited> | undefined; + let resolving = false; + // Consecutive failures, for backoff. The host being down is normal (restart, update) and must + // not mean a POST attempt every 2 s forever. + let failures = 0; + let skipTicks = 0; + + // The ORIGINAL function objects, unbound. `stop()` must put back exactly what it found: binding + // here and restoring the bound copy would leave a different function in place each cycle, so + // an install/stop/install sequence accumulates a wrapper per round. Calls go through `.call` + // below to keep `this` right without touching identity. + const original = Object.fromEntries(METHODS.map((m) => [m, console[m]])) as Record< + Method, + (...args: unknown[]) => void + >; + + const enqueue = (method: Method, args: unknown[]) => { + if (stopped) return; + try { + for (const line of toLines(method, args)) { + if (queue.length >= QUEUE_LIMIT) { + queue.shift(); + droppedByOverflow += 1; + } + queue.push(line); + } + } catch { + // Formatting a hostile object must never break the caller's console call. + } + }; + + for (const method of METHODS) { + console[method] = ((...args: unknown[]) => { + original[method].call(console, ...args); + enqueue(method, args); + }) as typeof console.log; + } + + const ensureResolved = async (): Promise => { + if (resolved) return true; + if (resolving) return false; + resolving = true; + try { + resolved = await resolveConfig(options.connect); + return true; + } catch { + // No token yet (or no host at all). Keep buffering and try again next tick. + return false; + } finally { + resolving = false; + } + }; + + /** Put a failed batch back at the FRONT, still honoring the cap (oldest lose). */ + const requeue = (batch: Line[]) => { + queue.unshift(...batch); + if (queue.length > QUEUE_LIMIT) { + droppedByOverflow += queue.length - QUEUE_LIMIT; + queue.splice(0, queue.length - QUEUE_LIMIT); + } + }; + + const flush = async (): Promise => { + if (stopped || shipping || queue.length === 0) return; + if (!(await ensureResolved()) || !resolved) return; + // Re-check after the await: `ensureResolved` yields, so another flush may have claimed the + // queue in the meantime. + if (stopped || shipping || queue.length === 0) return; + + shipping = true; + const batch = queue.splice(0, BATCH); + if (droppedByOverflow > 0) { + // Tell the operator the tail is incomplete rather than presenting a gap as continuity — + // the same contract the host's ring keeps with its `dropped` flag. + batch.unshift({ + ts_ms: Date.now(), + level: "WARN", + source: "runner", + msg: `log shipper dropped ${droppedByOverflow} line(s): the queue filled while the host was unreachable`, + }); + droppedByOverflow = 0; + } + + try { + const res = await resolved.fetch(`${resolved.url}/api/v1/plugins/logs`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${resolved.token}`, + }, + body: JSON.stringify({ entries: batch }), + }); + if (!res.ok) { + // 4xx is our bug (a shape the host rejects) and retrying cannot fix it — drop the + // batch. 5xx/transport is the host's problem and worth keeping. + if (res.status >= 500) requeue(batch); + // A token that went stale (host re-keyed) resolves again from disk on the next tick. + if (res.status === 401) resolved = undefined; + failures += 1; + } else { + failures = 0; + } + } catch { + requeue(batch); + failures += 1; + } finally { + shipping = false; + // 2 s, 4 s, 8 s … capped at ~30 s while the host stays away. + skipTicks = failures === 0 ? 0 : Math.min(2 ** (failures - 1), 15); + } + }; + + // The most recent flush, so an explicit `flush()` can WAIT for a periodic one rather than hit + // the re-entry guard and return having sent nothing. That matters on the shutdown path: the + // runner flushes once more after its units' finalizers have run, and those last lines are the + // ones that say whether the shutdown was clean. The window is widest exactly when the host is + // slow — which is when the logs are worth most. + let inFlight: Promise = Promise.resolve(); + const runFlush = (): Promise => { + inFlight = flush(); + return inFlight; + }; + + const timer = setInterval(() => { + if (skipTicks > 0) { + skipTicks -= 1; + return; + } + void runFlush(); + }, options.intervalMs ?? 2_000); + // The runner parks on its own keep-alive handle; this timer must not be what holds the process + // open, or a runner with nothing to run would never exit. + timer.unref?.(); + + return { + flush: async () => { + skipTicks = 0; + // `flush` never rejects (every path is caught); `.catch` only keeps that a guarantee. + await inFlight.catch(() => {}); + await runFlush(); + }, + stop: () => { + stopped = true; + clearInterval(timer); + for (const method of METHODS) { + console[method] = original[method] as typeof console.log; + } + }, + }; +}; + +/** Exported for tests. */ +export const __test = { toLines, STAMPED }; diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index b021db85..b9ff67fb 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -22,6 +22,7 @@ // plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a // package that may live on somebody else's registry — but they are ordinary CLI flags too. import { Effect, Fiber } from "effect"; +import { installLogShipper } from "./log-ship.js"; import { addPlugins, listInstalled, removePlugins } from "./plugins.js"; import { discoverUnits, runner } from "./runner.js"; @@ -157,16 +158,30 @@ if (process.argv.includes("--list")) { // nothing at all: field report 2026-07-25 had it pinning a full core indefinitely, `strace` // showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix. const keepAlive = setInterval(() => {}, 2 ** 31 - 1); + +// Tee this process's output to the host so the console's Logs page can show it. Installed HERE and +// not in `runner.ts`, so it covers the supervised run only: a plugin's own CLI builds the same +// layer graph, and an operator running `punktfunk-plugin-x doctor` in their terminal is not asking +// to write into the host's log. Must be installed before the runner starts — the lines that explain +// a plugin failing to load are the first ones out. +const shipper = installLogShipper(); + const fiber = Effect.runFork(runner(options)); let stopping = false; const shutdown = (signal: string) => { if (stopping) return process.exit(1); // second signal = get out now stopping = true; console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`); - void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0)); + void Effect.runPromise(Fiber.interrupt(fiber)) + // Ship what the finalizers just said before the process goes away — a clean shutdown's + // last lines are the ones that tell you whether it WAS clean. + .finally(() => shipper.flush()) + .finally(() => process.exit(0)); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); await Effect.runPromise(Fiber.await(fiber)); -clearInterval(keepAlive); // every unit ended on its own — let the process exit +await shipper.flush(); // every unit ended on its own — don't leave their last lines unsent +shipper.stop(); +clearInterval(keepAlive); // …then let the process exit diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts index 9ccad6e6..9a1732b3 100644 --- a/sdk/src/runner.ts +++ b/sdk/src/runner.ts @@ -41,10 +41,24 @@ export interface RunnerOptions { connect?: ConnectOptions; /** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */ restartBase?: Duration.Input; - /** Line sink. Default: stamped stdout. */ - log?: (line: string) => void; + /** + * Line sink. Default: stamped stdout, with `warn`/`error` going to the matching console method + * (hence stderr, and hence the right level in the console's log page — see `log-ship.ts`). + * + * `level` is optional so an existing `(line: string) => void` sink stays assignable. + */ + log?: (line: string, level?: RunnerLogLevel) => void; } +/** + * Severity of a runner line. Only three, because that is all the runner distinguishes: it is + * reporting on units, not producing application logs. + */ +export type RunnerLogLevel = "info" | "warn" | "error"; + +/** The sink shape used internally, with the level always supplied by the caller's default. */ +type LogSink = (line: string, level?: RunnerLogLevel) => void; + export interface Unit { /** Display name: the file stem, or the plugin package name. */ name: string; @@ -52,8 +66,12 @@ export interface Unit { file: string; } -const defaultLog = (line: string) => - console.log(`${new Date().toISOString()} ${line}`); +const defaultLog: LogSink = (line, level = "info") => { + const stamped = `${new Date().toISOString()} ${line}`; + if (level === "error") console.error(stamped); + else if (level === "warn") console.warn(stamped); + else console.log(stamped); +}; // ---- unit-file trust (the sshd rule, both halves) --------------------------------------------- @@ -225,7 +243,7 @@ const windowsPowershellEnv = (): Record => { }; /** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */ -const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { +const windowsFileIsSafe = (file: string, log: LogSink): boolean => { const escaped = file.replace(/'/g, "''"); const res = spawnSync( windowsPowershell(), @@ -244,7 +262,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { ); const sddl = res.status === 0 ? (res.stdout ?? "").trim() : ""; if (!sddl) { - log(`[runner] REFUSING ${file} — could not read its ACL`); + log(`[runner] REFUSING ${file} — could not read its ACL`, "error"); return false; } const reason = windowsSddlUnsafeReason(sddl, processSid()); @@ -253,6 +271,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { `[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` + `\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` + `non-admin write ACEs (icacls).`, + "error", ); return false; } @@ -264,13 +283,14 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { * could have written — group/world-writable mode on Unix; on Windows, an owner outside * SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal. */ -const fileIsSafe = (file: string, log: (l: string) => void): boolean => { +const fileIsSafe = (file: string, log: LogSink): boolean => { if (process.platform === "win32") return windowsFileIsSafe(file, log); try { const mode = fs.statSync(file).mode & 0o022; if (mode !== 0) { log( `[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`, + "error", ); return false; } @@ -285,7 +305,7 @@ const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]); /** Enumerate the operator's units: loose scripts plus installed plugin packages. */ export const discoverUnits = ( options: RunnerOptions = {}, - log: (l: string) => void = options.log ?? defaultLog, + log: LogSink = options.log ?? defaultLog, ): Unit[] => { const units: Unit[] = []; const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts"); @@ -332,7 +352,7 @@ export const discoverUnits = ( if (!fileIsSafe(file, log)) return; units.push({ name, file }); } catch (e) { - log(`[runner] skipping ${name}: unreadable package.json (${e})`); + log(`[runner] skipping ${name}: unreadable package.json (${e})`, "warn"); } }; try { @@ -379,7 +399,7 @@ const attemptUnit = ( unit: Unit, attempt: number, options: RunnerOptions, - log: (l: string) => void, + log: LogSink, ): Effect.Effect<"plugin" | "script", unknown> => Effect.gen(function* () { const mod = (yield* Effect.tryPromise( @@ -431,7 +451,8 @@ export const superviseUnit = ( let attempt = 0; const once = Effect.suspend(() => { attempt += 1; - if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`); + if (attempt > 1) + log(`[${unit.name}] restarting (attempt ${attempt})`, "warn"); return attemptUnit(unit, attempt, options, log); }); return once.pipe( @@ -446,13 +467,18 @@ export const superviseUnit = ( ), Effect.tapCause((cause) => Effect.sync(() => - log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`), + log( + `[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`, + "error", + ), ), ), Effect.retry(restart), Effect.catchCause((cause) => // A retry schedule that gives up (it doesn't, but stay total) — log and end. - Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)), + Effect.sync(() => + log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`, "error"), + ), ), Effect.asVoid, ); diff --git a/sdk/test/log-ship.test.ts b/sdk/test/log-ship.test.ts new file mode 100644 index 00000000..a088db48 --- /dev/null +++ b/sdk/test/log-ship.test.ts @@ -0,0 +1,329 @@ +// The log shipper's contract: what it recovers from a formatted line, that it tees rather than +// swallows, that a POST failure neither loses lines nor spins, and that it stays bounded. +import { afterEach, describe, expect, test } from "bun:test"; +import { __test, installLogShipper } from "../src/log-ship.js"; + +const { toLines } = __test; + +const TOKEN = "ship-token"; + +interface Captured { + entries: { ts_ms: number; level: string; source: string; msg: string }[]; +} + +/** A host that records every batch, answering with whatever `status()` says. */ +const mockHost = (status: () => number = () => 204) => { + const batches: Captured[] = []; + const auth: (string | null)[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + if (url.pathname !== "/api/v1/plugins/logs") { + return new Response("not found", { status: 404 }); + } + auth.push(req.headers.get("authorization")); + batches.push((await req.json()) as Captured); + const s = status(); + return new Response(s === 204 ? null : "nope", { status: s }); + }, + }); + return { + batches, + auth, + url: `http://127.0.0.1:${server.port}`, + stop: () => server.stop(true), + }; +}; + +/** + * Run `body` with a shipper installed, always restoring the console. + * + * The console is swapped for a recorder BEFORE the shipper installs, so `seen` is what the shipper + * teed through to "stdout" — and the suite stays readable, since a test that logs 2000 lines would + * otherwise print all 2000. + */ +const withShipper = async ( + url: string, + body: ( + s: ReturnType, + seen: string[], + ) => Promise, +): Promise => { + const seen: string[] = []; + const real = { log: console.log, warn: console.warn, error: console.error }; + const record = (...a: unknown[]) => { + seen.push(String(a[0])); + }; + console.log = record; + console.warn = record; + console.error = record; + const shipper = installLogShipper({ + connect: { url, token: TOKEN }, + // Long enough that only explicit flushes fire — the tests drive the timing. + intervalMs: 60_000, + }); + try { + return await body(shipper, seen); + } finally { + shipper.stop(); + console.log = real.log; + console.warn = real.warn; + console.error = real.error; + } +}; + +describe("parsing a formatted line", () => { + test("recovers the plugin name and timestamp plugin-kit's format flattened", () => { + const [line] = toLines( + "log", + ["2026-08-03T10:11:12.345Z [virtualhere] holding nothing"], + ); + expect(line?.source).toBe("virtualhere"); + expect(line?.level).toBe("INFO"); + expect(line?.msg).toBe("holding nothing"); + expect(line?.ts_ms).toBe(Date.parse("2026-08-03T10:11:12.345Z")); + }); + + test("an explicit level in the line beats the console method", () => { + // plugin-kit renders Effect's level verbatim — "WARNING", not "WARN". The host coerces it. + const [line] = toLines("log", [ + "2026-08-03T10:11:12.345Z [virtualhere] WARNING: vhclient failed (ETIMEDOUT)", + ]); + expect(line?.level).toBe("WARNING"); + expect(line?.msg).toBe("vhclient failed (ETIMEDOUT)"); + }); + + test("the runner's own error lines keep their severity", () => { + const [line] = toLines("error", [ + "2026-08-03T10:11:12.345Z [virtualhere] failed: VhIpcError: no such binary", + ]); + expect(line?.source).toBe("virtualhere"); + expect(line?.level).toBe("ERROR"); + }); + + test("an unstamped call is kept, not dropped", () => { + // A plugin reaching for bare console.error is exactly the case that must not be lost. + const [line] = toLines("error", ["boom", { code: 7 }]); + expect(line?.source).toBe("runner"); + expect(line?.level).toBe("ERROR"); + expect(line?.msg).toContain("boom"); + expect(line?.msg).toContain("7"); + }); + + test("a multi-line message becomes one entry per line", () => { + const lines = toLines("log", [ + "2026-08-03T10:11:12.345Z [x] failed\n at foo\n at bar", + ]); + expect(lines.map((l) => l.msg)).toEqual(["failed", " at foo", " at bar"]); + // Every fragment keeps the original stamp, so the trace cannot interleave with other units. + expect(new Set(lines.map((l) => l.ts_ms)).size).toBe(1); + }); + + test("a pathological dump is capped and says so", () => { + const lines = toLines("log", [ + `2026-08-03T10:11:12.345Z [x] ${"line\n".repeat(200)}`, + ]); + expect(lines.length).toBeLessThanOrEqual(41); + expect(lines.at(-1)?.msg).toContain("more line(s) suppressed"); + }); +}); + +describe("shipping", () => { + let stopHost: (() => void) | undefined; + afterEach(() => { + stopHost?.(); + stopHost = undefined; + }); + + test("tees: stdout still gets the line, and the host gets it too", async () => { + const host = mockHost(); + stopHost = host.stop; + const seen = await withShipper(host.url, async (shipper, seen) => { + console.log("2026-08-03T10:11:12.345Z [virtualhere] hello"); + await shipper.flush(); + return seen; + }); + + // The original console still ran — journald/foreground output is never traded away. + expect(seen).toEqual(["2026-08-03T10:11:12.345Z [virtualhere] hello"]); + expect(host.batches).toHaveLength(1); + expect(host.batches[0]?.entries[0]).toMatchObject({ + source: "virtualhere", + level: "INFO", + msg: "hello", + }); + expect(host.auth[0]).toBe(`Bearer ${TOKEN}`); + }); + + test("a 5xx keeps the lines for the next flush", async () => { + let status = 500; + const host = mockHost(() => status); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] keep me"); + await shipper.flush(); + expect(host.batches).toHaveLength(1); + status = 204; + await shipper.flush(); + }); + // Re-sent rather than dropped: the host being down is not the line's fault. + expect(host.batches).toHaveLength(2); + expect(host.batches[1]?.entries[0]?.msg).toBe("keep me"); + }); + + test("a 4xx drops the batch instead of retrying forever", async () => { + const host = mockHost(() => 400); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] malformed"); + await shipper.flush(); + await shipper.flush(); + }); + // A shape the host rejects cannot be fixed by sending it again. + expect(host.batches).toHaveLength(1); + }); + + test("a line logged WHILE a POST is in flight is not lost", async () => { + // A plugin logging during the few ms of a POST is the normal case under load, not an edge + // one. An earlier version held a `shipping` flag across the whole `await fetch` and dropped + // everything enqueued in that window — silently, which is the worst way to lose a log line. + let release: (() => void) | undefined; + const held = new Promise((r) => { + release = r; + }); + let arrived: (() => void) | undefined; + // Resolves once the server actually has the request — i.e. the shipper is genuinely mid-POST. + // Logging merely "after calling flush()" proves nothing: flush yields at its own awaits long + // before the fetch starts, so the line would land in the pre-send queue and the test would + // pass against the broken code. + const received = new Promise((r) => { + arrived = r; + }); + const batches: Captured[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + batches.push((await req.json()) as Captured); + arrived?.(); + await held; // hold this POST open + return new Response(null, { status: 204 }); + }, + }); + stopHost = () => server.stop(true); + + await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] before"); + const inFlight = shipper.flush(); + await received; + // The POST is open right now; this is the line that used to vanish. + console.log("2026-08-03T10:11:12.345Z [x] during"); + release?.(); + await inFlight; + await shipper.flush(); + }); + + const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); + expect(all).toContain("before"); + expect(all).toContain("during"); + }); + + test("an explicit flush waits for an in-flight one instead of no-opping", async () => { + // This is the shutdown path. The runner flushes once more after its units' finalizers have + // run, and those last lines are the ones that say whether the shutdown WAS clean. If a + // periodic flush happened to be mid-POST, an explicit flush that simply returned would + // leave them unsent — and the window is widest exactly when the host is slow, which is when + // the logs matter most. + let release: (() => void) | undefined; + const held = new Promise((r) => { + release = r; + }); + let arrived: (() => void) | undefined; + const received = new Promise((r) => { + arrived = r; + }); + let first = true; + const batches: Captured[] = []; + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + batches.push((await req.json()) as Captured); + if (first) { + first = false; + arrived?.(); + await held; + } + return new Response(null, { status: 204 }); + }, + }); + stopHost = () => server.stop(true); + + await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] first"); + const slow = shipper.flush(); + await received; + console.log("2026-08-03T10:11:12.345Z [x] shutdown line"); + release?.(); + // The shutdown flush: must not return until the tail is actually sent. + await shipper.flush(); + await slow; + }); + + const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); + expect(all).toContain("shutdown line"); + }); + + test("overlapping flushes do not double-send", async () => { + // The timer can fire while a slow POST is still open. Two concurrent flushes would splice + // disjoint batches out of one queue and deliver them out of order. + const host = mockHost(); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + console.log("2026-08-03T10:11:12.345Z [x] one"); + await Promise.all([shipper.flush(), shipper.flush()]); + }); + expect(host.batches).toHaveLength(1); + expect(host.batches[0]?.entries).toHaveLength(1); + }); + + test("an unreachable host neither throws into the caller nor grows without bound", async () => { + // Nothing listening on this port. + await withShipper("http://127.0.0.1:1", async (shipper) => { + for (let i = 0; i < 2_000; i++) { + console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); + } + // The whole point: console.log above must not have thrown, and flush must not reject. + await shipper.flush(); + }); + }); + + test("overflow is announced, not silently swallowed", async () => { + const host = mockHost(); + stopHost = host.stop; + await withShipper(host.url, async (shipper) => { + // Overrun the 1000-line cap while the shipper has had no chance to drain. + for (let i = 0; i < 1_200; i++) { + console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); + } + await shipper.flush(); + }); + const first = host.batches[0]?.entries[0]; + expect(first?.level).toBe("WARN"); + expect(first?.msg).toContain("dropped"); + // The tail is what survived — the oldest lines are the ones that went. + expect(host.batches[0]?.entries[1]?.msg).toBe("line 200"); + }); + + test("stop() puts the real console back", async () => { + const host = mockHost(); + stopHost = host.stop; + const before = console.log; + const shipper = installLogShipper({ + connect: { url: host.url, token: TOKEN }, + intervalMs: 60_000, + }); + expect(console.log).not.toBe(before); + shipper.stop(); + expect(console.log).toBe(before); + }); +}); diff --git a/web/messages/de.json b/web/messages/de.json index 0f78fabe..9112fab4 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -319,7 +319,11 @@ "nav_stats": "Leistung", "nav_logs": "Logs", "logs_title": "Logs", - "logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.", + "logs_subtitle": "Der aktuelle Log-Stream des Hosts und deiner Plugins — live verfolgen, nach Level filtern, durchsuchen.", + "logs_source_all": "Alle", + "logs_source_host": "Host", + "logs_source_plugins": "Plugins", + "logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.", "logs_follow": "Folgen", "logs_pause": "Pause", "logs_clear": "Leeren", diff --git a/web/messages/en.json b/web/messages/en.json index 7104f83a..224dae17 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -319,7 +319,11 @@ "nav_stats": "Performance", "nav_logs": "Logs", "logs_title": "Logs", - "logs_subtitle": "The host's recent log stream — follow live, filter by level, search.", + "logs_subtitle": "The host's recent log stream, and your plugins' — follow live, filter by level, search.", + "logs_source_all": "All", + "logs_source_host": "Host", + "logs_source_plugins": "Plugins", + "logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.", "logs_follow": "Follow", "logs_pause": "Pause", "logs_clear": "Clear", diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index bce3f6c0..c0dc62b1 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -38,6 +38,29 @@ const LEVEL_CLASS: Record = { const KEEP = 5_000; // accumulated entries (client memory bound) const SHOW = 1_000; // rendered rows (DOM bound) +/** + * Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner + * ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:`. Without this the two + * are interleaved with nothing but the target column to tell them apart, and "show me what my + * plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:` + * into the search box. + */ +const SOURCES = ["all", "host", "plugins"] as const; +type Source = (typeof SOURCES)[number]; + +/** The target prefix the host stamps on every runner-shipped line. */ +const PLUGIN_TARGET_PREFIX = "plugin:"; + +const matchesSource = (target: string, source: Source): boolean => + source === "all" || + (source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX); + +const SOURCE_LABEL: Record string> = { + all: () => m.logs_source_all(), + host: () => m.logs_source_host(), + plugins: () => m.logs_source_plugins(), +}; + /** * Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key, * so the next page fetches immediately and a backlog drains fast; an empty page leaves the key @@ -177,6 +200,7 @@ export const LogsCard: FC<{ onRetry, }) => { const [minLevel, setMinLevel] = useState("DEBUG"); + const [source, setSource] = useState("all"); const [search, setSearch] = useState(""); const listRef = useRef(null); @@ -186,11 +210,12 @@ export const LogsCard: FC<{ return entries.filter( (e) => (RANK[e.level] ?? 0) >= min && + matchesSource(e.target, source) && (q === "" || e.msg.toLowerCase().includes(q) || e.target.toLowerCase().includes(q)), ); - }, [entries, minLevel, search]); + }, [entries, minLevel, source, search]); const visible = useMemo(() => matched.slice(-SHOW), [matched]); const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy(); @@ -233,6 +258,18 @@ export const LogsCard: FC<{ ))}
+
+ {SOURCES.map((s) => ( + + ))} +
setSearch(e.target.value)} @@ -317,7 +354,15 @@ export const LogsCard: FC<{ ) : (

- {isLoading ? m.common_loading() : m.logs_empty()} + {isLoading + ? m.common_loading() + : // "No plugin output" has a specific, actionable cause that the generic + // "adjust the filter" line actively misdirects from: the runner is a + // separate service and is opt-in on Linux, so the usual reason for an + // empty Plugins view is that it simply isn't running. + source === "plugins" + ? m.logs_empty_plugins() + : m.logs_empty()}

)} diff --git a/web/src/stories/Logs.stories.tsx b/web/src/stories/Logs.stories.tsx index 85a7c4be..0112469b 100644 --- a/web/src/stories/Logs.stories.tsx +++ b/web/src/stories/Logs.stories.tsx @@ -64,6 +64,22 @@ const fixtureEntries: LogEntry[] = [ "punktfunk_host::encode", "NVENC opened 1920x1080 nv12 gop=inf rfi=on", ), + // Lines the plugin runner shipped up (`POST /api/v1/plugins/logs`), targeted `plugin:`. + // They share the ring and the cursor with the host's own, which is what the Host/Plugins + // filter exists to separate — so the fixture has to carry both to be worth screenshotting. + entry(9, "INFO", "plugin:runner", "starting virtualhere"), + entry( + 10, + "INFO", + "plugin:virtualhere", + "bound couch-deck.11 (Thrustmaster T300RS) for stream", + ), + entry( + 11, + "ERROR", + "plugin:virtualhere", + "vhclientx86_64 failed (ETIMEDOUT) — the VirtualHere client is not answering on /tmp/vhclient", + ), ]; const meta = {