diff --git a/api/openapi.json b/api/openapi.json index aaa79c09..5f884a8f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -5106,7 +5106,7 @@ "properties": { "audio_streaming": { "type": "boolean", - "description": "True while the audio stream thread is running." + "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }, "conflicts": { "type": "array", @@ -5150,7 +5150,7 @@ }, { "$ref": "#/components/schemas/SessionInfo", - "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." + "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." } ] }, @@ -5160,7 +5160,7 @@ }, "video_streaming": { "type": "boolean", - "description": "True while the video stream thread is running." + "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." } } }, diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 43b47f1a..f8fa6a16 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -150,11 +150,13 @@ pub(crate) struct StreamInfo { pub(crate) struct LocalSummary { /// Host version (mirrors `/health`). version: String, - /// True while the video stream thread is running. + /// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live + /// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone. video_streaming: bool, - /// True while the audio stream thread is running. + /// True while audio is streaming on either plane (same rule as `video_streaming`). audio_streaming: bool, - /// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop). + /// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first + /// live native session. `null` when nothing is streaming. session: Option, /// Number of pinned (paired) GameStream client certificates. paired_clients: u32, @@ -391,26 +393,27 @@ pub(crate) async fn get_status(State(st): State>) -> Json>) -> Json { + // Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for + // both the session card and the streaming flags below. + let native = crate::session_status::snapshot(); // GameStream launch, else the first live native session — so the tray reflects a native session // too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`). let session = st .app .launch .lock() - .unwrap() + .unwrap_or_else(|e| e.into_inner()) .map(|l| SessionInfo { width: l.width, height: l.height, fps: l.fps, }) .or_else(|| { - crate::session_status::snapshot() - .first() - .map(|s| SessionInfo { - width: s.width, - height: s.height, - fps: s.fps, - }) + native.first().map(|s| SessionInfo { + width: s.width, + height: s.height, + fps: s.fps, + }) }); let (native_paired_clients, pending_approvals) = st .native @@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State>) -> Json< .unwrap_or((0, 0)); Json(LocalSummary { version: env!("PUNKTFUNK_VERSION").into(), - video_streaming: st.app.streaming.load(Ordering::SeqCst), - audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst), + // Either plane counts, like `/status`: reading only the GameStream flags made the tray say + // "idle" (and wear the idle icon) through an entire native session. + video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(), + audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(), session, paired_clients: st .app diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index b1e85b83..22575c64 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() { assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION); } +/// Serializes the tests that read (or write) the process-global live-session registry +/// ([`crate::session_status`]): a session registered by one test would otherwise make a +/// concurrently running one see a stream it never started. +static SESSION_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// A `/local/summary` request from a loopback peer (the tray's own). +fn summary_req() -> axum::http::Request { + let mut req = get_req("/api/v1/local/summary"); + req.extensions_mut() + .insert(PeerAddr("127.0.0.1:40000".parse().unwrap())); + req +} + +/// Registers a stand-in live native session; the returned guard removes it on drop. +fn fake_native_session( + width: u32, + height: u32, + fps: u32, +) -> crate::session_status::LiveSessionGuard { + let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64; + crate::session_status::register( + Arc::new(std::sync::atomic::AtomicU64::new(packed)), + Arc::new(std::sync::atomic::AtomicU32::new(20_000)), + Codec::H265, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + "test-client".into(), + false, + Arc::new(std::sync::atomic::AtomicU32::new(0)), + Arc::new(std::sync::atomic::AtomicU32::new(0)), + ) +} + +/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's +/// summary. The GameStream `streaming` flag stays false throughout such a session, and reading it +/// alone left the tray showing "idle" (with the idle icon) for the whole stream: exactly the blind +/// spot `/status` was fixed for in [`crate::session_status`], which `/local/summary` still had. +#[tokio::test] +async fn local_summary_reports_a_native_session_as_streaming() { + let _serial = SESSION_REGISTRY_LOCK.lock().await; + let app = test_app(test_state(), None); + + let (status, body) = send(&app, summary_req()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["video_streaming"], false); + assert_eq!(body["session"], serde_json::Value::Null); + + let session = fake_native_session(3840, 2160, 120); + let (_, body) = send(&app, summary_req()).await; + assert_eq!(body["video_streaming"], true, "native session: {body}"); + assert_eq!(body["audio_streaming"], true, "native session: {body}"); + assert_eq!(body["session"]["width"], 3840); + assert_eq!(body["session"]["height"], 2160); + assert_eq!(body["session"]["fps"], 120); + + // Session over → back to idle. + drop(session); + let (_, body) = send(&app, summary_req()).await; + assert_eq!(body["video_streaming"], false); + assert_eq!(body["session"], serde_json::Value::Null); +} + /// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is /// rejected even though the route needs no bearer token, and the body never carries secret /// material (no PIN values, no fingerprints, no device names — counts/booleans only). #[tokio::test] async fn local_summary_is_loopback_only_and_non_sensitive() { + let _serial = SESSION_REGISTRY_LOCK.lock().await; let np = Arc::new( crate::native_pairing::NativePairing::load_with( Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))), @@ -600,6 +663,7 @@ async fn compositors_lists_all_backends_with_flags() { #[tokio::test] async fn status_reflects_runtime_state() { + let _serial = SESSION_REGISTRY_LOCK.lock().await; let state = test_state(); let app = test_app(state.clone(), None); @@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() { #[tokio::test] async fn idr_requires_an_active_stream() { + // A live native session (registered by a sibling test) is an active stream to this route. + let _serial = SESSION_REGISTRY_LOCK.lock().await; let state = test_state(); let app = test_app(state.clone(), None); let post = || { diff --git a/crates/punktfunk-tray/src/linux.rs b/crates/punktfunk-tray/src/linux.rs index 04cfaaf6..854bf0ce 100644 --- a/crates/punktfunk-tray/src/linux.rs +++ b/crates/punktfunk-tray/src/linux.rs @@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus}; struct HostTray { status: TrayStatus, web_port: u16, - /// The console answered the poller's live loopback probe — the "Open web console" entry is - /// shown iff opening it would actually work (repo-run consoles included, stopped ones not). + /// The console answered the poller's live loopback probe — labels the (always present) "Open + /// web console" entry; it never hides it. web_console: bool, /// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions /// force an immediate re-poll instead of waiting out the cadence. @@ -33,8 +33,10 @@ impl HostTray { } } - fn open_console(&self) { - let url = format!("https://127.0.0.1:{}", self.web_port); + /// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page + /// the menu entry promised — the pairing queue, the virtual displays. + fn open_console(&self, path: &str) { + let url = format!("https://127.0.0.1:{}/{path}", self.web_port); let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } } @@ -107,17 +109,33 @@ impl ksni::Tray for HostTray { } .into(), MenuItem::Separator, + // Always present — it is the reason most people open this menu. When the loopback + // probe says the console isn't answering, the label says so rather than the entry + // disappearing (a menu missing its main action reads as a broken tray). StandardItem { - label: "Open web console".into(), - visible: self.web_console, - activate: Box::new(|t: &mut Self| t.open_console()), + label: if self.web_console { + "Open web console".to_string() + } else { + "Open web console (not responding)".to_string() + }, + activate: Box::new(|t: &mut Self| t.open_console("")), ..Default::default() } .into(), StandardItem { label: "Approve pairing request…".into(), - visible: self.web_console && self.status.pairing_attention(), - activate: Box::new(|t: &mut Self| t.open_console()), + visible: self.status.pairing_attention(), + activate: Box::new(|t: &mut Self| t.open_console("pairing")), + ..Default::default() + } + .into(), + StandardItem { + label: match self.status.kept_displays() { + 1 => "Release kept display…".to_string(), + n => format!("Release {n} kept displays…"), + }, + visible: self.status.kept_displays() > 0, + activate: Box::new(|t: &mut Self| t.open_console("displays")), ..Default::default() } .into(), diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs index bb825938..fdbb3896 100644 --- a/crates/punktfunk-tray/src/status.rs +++ b/crates/punktfunk-tray/src/status.rs @@ -75,7 +75,7 @@ impl TrayStatus { TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(), TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"), TrayStatus::Running(s) => { - let base = match (&s.session, s.video_streaming) { + let base = match (&s.session, self.is_streaming()) { (Some(sess), true) => format!( "punktfunk host {} — streaming {}×{}@{}", s.version, sess.width, sess.height, sess.fps @@ -113,8 +113,23 @@ impl TrayStatus { matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty()) } + /// A client is streaming: the host's flag, OR a live session in the summary. + /// + /// The session is checked too because a host from before the `get_local_summary` fix raised + /// `video_streaming` from the GameStream plane only — through a whole session on the native + /// (default) plane it said false while still reporting that session's mode, which is what left + /// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host. pub fn is_streaming(&self) -> bool { - matches!(self, TrayStatus::Running(s) if s.video_streaming) + matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some()) + } + + /// Virtual displays held with no live session (lingering/pinned) — offered as a one-click + /// release in the menu, since holding one can also be keeping physical monitors dark. + pub fn kept_displays(&self) -> u32 { + match self { + TrayStatus::Running(s) => s.kept_displays, + _ => 0, + } } /// A pairing attempt is waiting on the operator (shown as an extra menu entry). @@ -156,9 +171,11 @@ struct Shared { impl Poller { /// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever - /// either changes. `console_up` is a live loopback probe of the web console on `web_port` — - /// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run - /// from a repo checkout, and shows a dead entry while an installed console is still starting). + /// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It + /// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry + /// is the tray's most-wanted action, and a menu that silently drops it — because the console + /// was still starting, or the probe timed out — is indistinguishable from a tray that never + /// had one. pub fn spawn( mgmt_addr: String, mgmt_port: u16, @@ -203,6 +220,10 @@ fn poll_loop( // When the summary became unreachable while the service was running (grace anchor). // Runs for the process lifetime (the tray exits by process exit; nothing to unwind). let mut unreachable_since: Option = None; + // Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR + // whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes + // its label every few seconds reads as broken. + let mut console_misses = 0u32; loop { let svc = probe_service(); let summary = if svc == ServiceState::Running { @@ -219,7 +240,13 @@ fn poll_loop( }; let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE); let status = map_status(&svc, summary, grace_expired); - let console_up = probe_console(&agent, &console_url); + let console_up = if probe_console(&agent, &console_url) { + console_misses = 0; + true + } else { + console_misses += 1; + console_misses < 2 + }; if last.as_ref() != Some(&(status.clone(), console_up)) { on_change(status.clone(), console_up); last = Some((status, console_up)); @@ -478,6 +505,32 @@ mod tests { .contains("status unavailable")); } + /// A live session means streaming even if the host's flag says otherwise — a host from before + /// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a + /// native session showed as "idle" with its own mode printed next to it. + #[test] + fn a_live_session_reads_as_streaming_without_the_flag() { + let mut s = summary(true); + s.video_streaming = false; // pre-fix host, native session + let st = TrayStatus::Running(s); + assert!(st.is_streaming()); + assert_eq!( + st.headline(), + "punktfunk host 0.5.1 — streaming 2560×1440@120" + ); + // No session and no flag is still idle. + assert!(!TrayStatus::Running(summary(false)).is_streaming()); + } + + #[test] + fn kept_displays_are_reported_for_the_release_action() { + assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0); + let mut s = summary(false); + s.kept_displays = 2; + assert_eq!(TrayStatus::Running(s).kept_displays(), 2); + assert_eq!(TrayStatus::Degraded.kept_displays(), 0); + } + #[test] fn pairing_attention_flags() { let mut s = summary(false); diff --git a/crates/punktfunk-tray/src/win.rs b/crates/punktfunk-tray/src/win.rs index b1478047..8c50cd31 100644 --- a/crates/punktfunk-tray/src/win.rs +++ b/crates/punktfunk-tray/src/win.rs @@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104; const IDM_LOGS: usize = 0x0105; const IDM_EXIT: usize = 0x0106; const IDM_PAIRING: usize = 0x0107; +const IDM_DISPLAYS: usize = 0x0108; /// Icon resource ordinals (embedded by build.rs). fn icon_ordinal(status: &TrayStatus) -> u16 { @@ -73,8 +74,9 @@ struct App { taskbar_created: u32, /// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`). host_exe: Option, - /// The console answered the poller's live loopback probe — the "Open web console" entry is - /// shown iff opening it would actually work (repo-run consoles included, stopped ones not). + /// The console answered the poller's live loopback probe. Drives the label of the (always + /// present) "Open web console" entry, and whether a left-click on the icon opens the console + /// or falls back to showing the menu. web_console: AtomicBool, web_port: u16, } @@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) { }; add(IDM_HEADER, &status.headline(), true); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + // The console entry is ALWAYS here — it is the reason most people open this menu, and + // left-clicking the icon is not a discoverable substitute. When the loopback probe says + // the console isn't answering the label says so, rather than the entry vanishing. if app().web_console.load(Ordering::SeqCst) { add(IDM_OPEN_WEB, "Open web console", false); - let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0); - if status.pairing_attention() { - add(IDM_PAIRING, "Approve pairing request…", false); - } - let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + } else { + add(IDM_OPEN_WEB, "Open web console (not responding)", false); } + let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0); + if status.pairing_attention() { + add(IDM_PAIRING, "Approve pairing request…", false); + } + match status.kept_displays() { + 0 => {} + 1 => add(IDM_DISPLAYS, "Release kept display…", false), + n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false), + } + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); if can_control { if startable { add(IDM_START, "Start host", false); @@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) { } } -fn open_web_console(hwnd: HWND) { - shell_open(hwnd, &format!("https://localhost:{}", app().web_port)); +/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the +/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard. +fn open_web_console(hwnd: HWND, path: &str) { + shell_open( + hwnd, + &format!("https://localhost:{}/{path}", app().web_port), + ); } fn open_logs(hwnd: HWND) { @@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) WM_CONTEXTMENU => show_menu(hwnd), x if x == NIN_SELECT || x == NIN_KEYSELECT => { if app.web_console.load(Ordering::SeqCst) { - open_web_console(hwnd); + open_web_console(hwnd, ""); } else { show_menu(hwnd); } @@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) } WM_COMMAND => { match (wparam.0) & 0xffff { - IDM_OPEN_WEB => open_web_console(hwnd), - IDM_PAIRING => open_web_console(hwnd), + IDM_OPEN_WEB => open_web_console(hwnd, ""), + IDM_PAIRING => open_web_console(hwnd, "pairing"), + IDM_DISPLAYS => open_web_console(hwnd, "displays"), IDM_START => elevate_service(hwnd, "start"), IDM_STOP => elevate_service(hwnd, "stop"), IDM_RESTART => elevate_service(hwnd, "restart"), diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index a40a8eb3..e14a3cd7 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -54,7 +54,7 @@ export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "desc export type LayoutMode = "auto-row" | "manual" export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." }) export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean } -export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's 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": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." }) +export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's 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": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." }) export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number } export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." }) export type ModeConflict = "separate" | "steal" | "join" | "reject"