fix(tray): count native sessions as streaming, and stop hiding "Open web console"
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 1m1s
windows-host / package (push) Failing after 4m40s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m31s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
arch / build-publish (push) Successful in 11m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m36s
deb / build-publish (push) Successful in 9m54s
deb / build-publish-host (push) Successful in 9m42s
ci / rust (push) Failing after 21m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m57s
apple / screenshots (push) Successful in 22m35s
docker / deploy-docs (push) Successful in 25s
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 1m1s
windows-host / package (push) Failing after 4m40s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m31s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
arch / build-publish (push) Successful in 11m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m36s
deb / build-publish (push) Successful in 9m54s
deb / build-publish-host (push) Successful in 9m42s
ci / rust (push) Failing after 21m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m57s
apple / screenshots (push) Successful in 22m35s
docker / deploy-docs (push) Successful in 25s
The tray sat at "Idle" through an entire stream, and its Windows context menu could come up without its main action. Two separate causes. `GET /api/v1/local/summary` reported `video_streaming`/`audio_streaming` straight from `AppState.streaming` — flags only the GameStream media pipeline raises. The native punktfunk/1 plane is the DEFAULT (GameStream is opt-in) and never touches them, so every native session read as idle: idle icon, and a tooltip that printed that session's own resolution next to the word "idle" (the `session` field was already native-aware). This is the blind spot `/status` was fixed for — see the `session_status` module doc — which the summary kept. Both flags now OR in a live native session, and the tray additionally treats a reported session as streaming, so a new tray reads an older host correctly too. The menu built "Open web console" (and the pairing entry) only while a live loopback probe of the console had just succeeded. A console still starting, or an SSR render slower than the 2 s probe timeout, therefore deleted the tray's most-wanted action outright — indistinguishable from a tray that never had one, with left-clicking the icon as the only, undiscoverable, way in. The entry is now always present and the default item; a failed probe changes its label to "(not responding)" instead of hiding it, and takes two consecutive misses to say so, since one timeout is not "down". While here: a "Release kept display…" entry when displays are held (the summary field's own doc promised a one-click release that did not exist), and entries deep-link to /pairing and /displays instead of all landing on the dashboard. The Linux SNI menu mirrors all of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -5106,7 +5106,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"audio_streaming": {
|
"audio_streaming": {
|
||||||
"type": "boolean",
|
"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": {
|
"conflicts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -5150,7 +5150,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionInfo",
|
"$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": {
|
"video_streaming": {
|
||||||
"type": "boolean",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
|
|||||||
pub(crate) struct LocalSummary {
|
pub(crate) struct LocalSummary {
|
||||||
/// Host version (mirrors `/health`).
|
/// Host version (mirrors `/health`).
|
||||||
version: String,
|
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,
|
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,
|
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<SessionInfo>,
|
session: Option<SessionInfo>,
|
||||||
/// Number of pinned (paired) GameStream client certificates.
|
/// Number of pinned (paired) GameStream client certificates.
|
||||||
paired_clients: u32,
|
paired_clients: u32,
|
||||||
@@ -391,22 +393,23 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||||
|
// 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
|
// 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`).
|
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||||
let session = st
|
let session = st
|
||||||
.app
|
.app
|
||||||
.launch
|
.launch
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.map(|l| SessionInfo {
|
.map(|l| SessionInfo {
|
||||||
width: l.width,
|
width: l.width,
|
||||||
height: l.height,
|
height: l.height,
|
||||||
fps: l.fps,
|
fps: l.fps,
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
crate::session_status::snapshot()
|
native.first().map(|s| SessionInfo {
|
||||||
.first()
|
|
||||||
.map(|s| SessionInfo {
|
|
||||||
width: s.width,
|
width: s.width,
|
||||||
height: s.height,
|
height: s.height,
|
||||||
fps: s.fps,
|
fps: s.fps,
|
||||||
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
|||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
Json(LocalSummary {
|
Json(LocalSummary {
|
||||||
version: env!("PUNKTFUNK_VERSION").into(),
|
version: env!("PUNKTFUNK_VERSION").into(),
|
||||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
|
||||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
// "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,
|
session,
|
||||||
paired_clients: st
|
paired_clients: st
|
||||||
.app
|
.app
|
||||||
|
|||||||
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
|
|||||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
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<Body> {
|
||||||
|
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
|
/// 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
|
/// 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).
|
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
let np = Arc::new(
|
let np = Arc::new(
|
||||||
crate::native_pairing::NativePairing::load_with(
|
crate::native_pairing::NativePairing::load_with(
|
||||||
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
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]
|
#[tokio::test]
|
||||||
async fn status_reflects_runtime_state() {
|
async fn status_reflects_runtime_state() {
|
||||||
|
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let app = test_app(state.clone(), None);
|
let app = test_app(state.clone(), None);
|
||||||
|
|
||||||
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idr_requires_an_active_stream() {
|
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 state = test_state();
|
||||||
let app = test_app(state.clone(), None);
|
let app = test_app(state.clone(), None);
|
||||||
let post = || {
|
let post = || {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
|
|||||||
struct HostTray {
|
struct HostTray {
|
||||||
status: TrayStatus,
|
status: TrayStatus,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
/// The console answered the poller's live loopback probe — labels the (always present) "Open
|
||||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
/// web console" entry; it never hides it.
|
||||||
web_console: bool,
|
web_console: bool,
|
||||||
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
/// 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.
|
/// force an immediate re-poll instead of waiting out the cadence.
|
||||||
@@ -33,8 +33,10 @@ impl HostTray {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_console(&self) {
|
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
|
||||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
/// 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();
|
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
MenuItem::Separator,
|
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 {
|
StandardItem {
|
||||||
label: "Open web console".into(),
|
label: if self.web_console {
|
||||||
visible: self.web_console,
|
"Open web console".to_string()
|
||||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
} else {
|
||||||
|
"Open web console (not responding)".to_string()
|
||||||
|
},
|
||||||
|
activate: Box::new(|t: &mut Self| t.open_console("")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Approve pairing request…".into(),
|
label: "Approve pairing request…".into(),
|
||||||
visible: self.web_console && self.status.pairing_attention(),
|
visible: self.status.pairing_attention(),
|
||||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
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()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ impl TrayStatus {
|
|||||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||||
TrayStatus::Running(s) => {
|
TrayStatus::Running(s) => {
|
||||||
let base = match (&s.session, s.video_streaming) {
|
let base = match (&s.session, self.is_streaming()) {
|
||||||
(Some(sess), true) => format!(
|
(Some(sess), true) => format!(
|
||||||
"punktfunk host {} — streaming {}×{}@{}",
|
"punktfunk host {} — streaming {}×{}@{}",
|
||||||
s.version, sess.width, sess.height, sess.fps
|
s.version, sess.width, sess.height, sess.fps
|
||||||
@@ -113,8 +113,23 @@ impl TrayStatus {
|
|||||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
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 {
|
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).
|
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||||
@@ -156,9 +171,11 @@ struct Shared {
|
|||||||
|
|
||||||
impl Poller {
|
impl Poller {
|
||||||
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
/// 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` —
|
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
|
||||||
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
|
||||||
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
/// 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(
|
pub fn spawn(
|
||||||
mgmt_addr: String,
|
mgmt_addr: String,
|
||||||
mgmt_port: u16,
|
mgmt_port: u16,
|
||||||
@@ -203,6 +220,10 @@ fn poll_loop(
|
|||||||
// When the summary became unreachable while the service was running (grace anchor).
|
// 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).
|
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||||
let mut unreachable_since: Option<Instant> = None;
|
let mut unreachable_since: Option<Instant> = 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 {
|
loop {
|
||||||
let svc = probe_service();
|
let svc = probe_service();
|
||||||
let summary = if svc == ServiceState::Running {
|
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 grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||||
let status = map_status(&svc, summary, grace_expired);
|
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)) {
|
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||||
on_change(status.clone(), console_up);
|
on_change(status.clone(), console_up);
|
||||||
last = Some((status, console_up));
|
last = Some((status, console_up));
|
||||||
@@ -478,6 +505,32 @@ mod tests {
|
|||||||
.contains("status unavailable"));
|
.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]
|
#[test]
|
||||||
fn pairing_attention_flags() {
|
fn pairing_attention_flags() {
|
||||||
let mut s = summary(false);
|
let mut s = summary(false);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
|
|||||||
const IDM_LOGS: usize = 0x0105;
|
const IDM_LOGS: usize = 0x0105;
|
||||||
const IDM_EXIT: usize = 0x0106;
|
const IDM_EXIT: usize = 0x0106;
|
||||||
const IDM_PAIRING: usize = 0x0107;
|
const IDM_PAIRING: usize = 0x0107;
|
||||||
|
const IDM_DISPLAYS: usize = 0x0108;
|
||||||
|
|
||||||
/// Icon resource ordinals (embedded by build.rs).
|
/// Icon resource ordinals (embedded by build.rs).
|
||||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||||
@@ -73,8 +74,9 @@ struct App {
|
|||||||
taskbar_created: u32,
|
taskbar_created: u32,
|
||||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||||
host_exe: Option<std::path::PathBuf>,
|
host_exe: Option<std::path::PathBuf>,
|
||||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
/// The console answered the poller's live loopback probe. Drives the label of the (always
|
||||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
/// 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_console: AtomicBool,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
}
|
}
|
||||||
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
|
|||||||
};
|
};
|
||||||
add(IDM_HEADER, &status.headline(), true);
|
add(IDM_HEADER, &status.headline(), true);
|
||||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
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) {
|
if app().web_console.load(Ordering::SeqCst) {
|
||||||
add(IDM_OPEN_WEB, "Open web console", false);
|
add(IDM_OPEN_WEB, "Open web console", false);
|
||||||
|
} else {
|
||||||
|
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||||
|
}
|
||||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||||
if status.pairing_attention() {
|
if status.pairing_attention() {
|
||||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||||
}
|
}
|
||||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
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 can_control {
|
||||||
if startable {
|
if startable {
|
||||||
add(IDM_START, "Start host", false);
|
add(IDM_START, "Start host", false);
|
||||||
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_web_console(hwnd: HWND) {
|
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
/// 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) {
|
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),
|
WM_CONTEXTMENU => show_menu(hwnd),
|
||||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||||
if app.web_console.load(Ordering::SeqCst) {
|
if app.web_console.load(Ordering::SeqCst) {
|
||||||
open_web_console(hwnd);
|
open_web_console(hwnd, "");
|
||||||
} else {
|
} else {
|
||||||
show_menu(hwnd);
|
show_menu(hwnd);
|
||||||
}
|
}
|
||||||
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
|||||||
}
|
}
|
||||||
WM_COMMAND => {
|
WM_COMMAND => {
|
||||||
match (wparam.0) & 0xffff {
|
match (wparam.0) & 0xffff {
|
||||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
IDM_OPEN_WEB => open_web_console(hwnd, ""),
|
||||||
IDM_PAIRING => 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_START => elevate_service(hwnd, "start"),
|
||||||
IDM_STOP => elevate_service(hwnd, "stop"),
|
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||||
IDM_RESTART => elevate_service(hwnd, "restart"),
|
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "desc
|
|||||||
export type LayoutMode = "auto-row" | "manual"
|
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 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<string>, 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 type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, 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 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 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"
|
export type ModeConflict = "separate" | "steal" | "join" | "reject"
|
||||||
|
|||||||
Reference in New Issue
Block a user