From d285d4a0b2acd6f13795bb2571d364b6dbb8daa5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 12:17:01 +0000 Subject: [PATCH] fix(tray): live-probe the web console instead of sniffing the install layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Open web console" entry was gated on {exe dir}\web\web-run.cmd (Windows) / the punktfunk-web unit file (Linux) — which misses consoles run from a repo checkout (the RTX box, caught on-glass) and shows a dead entry while an installed console is stopped. The poller now probes https://127.0.0.1:/ each cycle (any HTTP response = up, transport failure = down) and the menu follows live on both platforms. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-tray/src/linux.rs | 42 +++++++++-------------------- crates/punktfunk-tray/src/status.rs | 35 +++++++++++++++++------- crates/punktfunk-tray/src/win.rs | 27 +++++++++---------- 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/crates/punktfunk-tray/src/linux.rs b/crates/punktfunk-tray/src/linux.rs index bd11222..7a0c520 100644 --- a/crates/punktfunk-tray/src/linux.rs +++ b/crates/punktfunk-tray/src/linux.rs @@ -10,11 +10,13 @@ use std::sync::{Arc, OnceLock}; use crate::status::{self, Poller, TrayStatus}; -/// The tray's D-Bus/menu model. `status` is the only mutable state; the poller rewrites it via -/// `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu). +/// The tray's D-Bus/menu model. `status` + `web_console` are the mutable state; the poller +/// rewrites them via `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu). 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). 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. @@ -192,28 +194,6 @@ fn host_present() -> bool { .is_ok_and(|s| s.success()) } -/// The web console is a separate optional unit/package — only offer "Open web console" when it -/// exists for this user. -fn web_console_installed() -> bool { - let unit = "punktfunk-web.service"; - if ["/usr/lib/systemd/user", "/etc/systemd/user"] - .iter() - .any(|d| std::path::Path::new(d).join(unit).exists()) - { - return true; - } - if let Some(home) = std::env::var_os("HOME") { - if std::path::PathBuf::from(home) - .join(".config/systemd/user") - .join(unit) - .exists() - { - return true; - } - } - false -} - /// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime). fn acquire_instance_lock() -> Option { let dir = std::env::var_os("XDG_RUNTIME_DIR") @@ -247,7 +227,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { let tray = HostTray { status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle web_port: args.web_port, - web_console: web_console_installed(), + web_console: false, // live-probed by the poller within its first cycle poller: poller_slot.clone(), }; // Autostart races the desktop (the watcher may register after us) → be lenient and wait for @@ -271,11 +251,13 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { let poller = Poller::spawn( args.mgmt_addr.clone(), args.mgmt_port, - Box::new(move |st| { - if update_handle - .update(|t: &mut HostTray| t.status = st) - .is_none() - { + args.web_port, + Box::new(move |st, console_up| { + let updated = update_handle.update(|t: &mut HostTray| { + t.status = st; + t.web_console = console_up; + }); + if updated.is_none() { dead_flag.store(true, Ordering::SeqCst); // tray service shut down } }), diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs index 72ea9f4..c8d97d8 100644 --- a/crates/punktfunk-tray/src/status.rs +++ b/crates/punktfunk-tray/src/status.rs @@ -118,11 +118,15 @@ struct Shared { } impl Poller { - /// Spawn the poll thread; `on_change` fires (from that thread) whenever the status changes. + /// 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). pub fn spawn( mgmt_addr: String, mgmt_port: u16, - on_change: Box, + web_port: u16, + on_change: Box, ) -> Poller { let shared = Arc::new(Shared { poked: Mutex::new(false), @@ -131,7 +135,7 @@ impl Poller { let thread_shared = shared.clone(); std::thread::Builder::new() .name("status-poll".into()) - .spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, on_change)) + .spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, web_port, on_change)) .expect("spawn status-poll thread"); Poller { shared } } @@ -147,7 +151,8 @@ fn poll_loop( shared: &Shared, mgmt_addr: &str, mgmt_port: u16, - on_change: Box, + web_port: u16, + on_change: Box, ) { // IPv6 literals bracketed, like the Linux client's `base_url`. let url = if mgmt_addr.contains(':') { @@ -155,8 +160,9 @@ fn poll_loop( } else { format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary") }; + let console_url = format!("https://127.0.0.1:{web_port}/"); let agent = agent(load_pin()); - let mut last: Option = None; + let mut last: Option<(TrayStatus, bool)> = None; // 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; @@ -176,12 +182,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); - if last.as_ref() != Some(&status) { - on_change(status.clone()); - last = Some(status); + let console_up = probe_console(&agent, &console_url); + if last.as_ref() != Some(&(status.clone(), console_up)) { + on_change(status.clone(), console_up); + last = Some((status, console_up)); } // 3 s while there is anything to watch; back off when the box just doesn't run a host. - let cadence = match last { + let cadence = match last.as_ref().map(|(s, _)| s) { Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10), _ => Duration::from_secs(3), }; @@ -193,6 +200,16 @@ fn poll_loop( } } +/// Is the web console answering on loopback? Any HTTP response (incl. the login redirect / 401) +/// counts as up — only a transport failure (nothing listening, TLS handshake dead) means down. +fn probe_console(agent: &ureq::Agent, url: &str) -> bool { + match agent.get(url).call() { + Ok(_) => true, + Err(ureq::Error::Status(..)) => true, + Err(_) => false, + } +} + // ── Summary fetch (loopback HTTPS) ────────────────────────────────────────────────────────────── fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option { diff --git a/crates/punktfunk-tray/src/win.rs b/crates/punktfunk-tray/src/win.rs index e28de01..b147804 100644 --- a/crates/punktfunk-tray/src/win.rs +++ b/crates/punktfunk-tray/src/win.rs @@ -8,7 +8,7 @@ //! left admin-gated rather than DACL-opened to every local user. use std::os::windows::ffi::OsStrExt; -use std::sync::atomic::{AtomicIsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; use std::sync::{Mutex, OnceLock}; use windows::core::{w, PCWSTR}; @@ -73,8 +73,9 @@ struct App { taskbar_created: u32, /// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`). host_exe: Option, - /// The installer bundled the web console (detected via `{app}\web\web-run.cmd`). - web_console: bool, + /// 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). + web_console: AtomicBool, web_port: u16, } @@ -125,16 +126,10 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { return Ok(()); } - let exe_dir = std::env::current_exe() + let host_exe = std::env::current_exe() .ok() - .and_then(|p| p.parent().map(|d| d.to_path_buf())); - let host_exe = exe_dir - .as_ref() - .map(|d| d.join("punktfunk-host.exe")) + .and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe"))) .filter(|p| p.exists()); - let web_console = exe_dir - .as_ref() - .is_some_and(|d| d.join("web").join("web-run.cmd").exists()); // SAFETY: RegisterWindowMessageW with a static nul-terminated literal. let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) }; @@ -144,7 +139,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { poller: OnceLock::new(), taskbar_created, host_exe, - web_console, + web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle web_port: args.web_port, }) .ok() @@ -200,8 +195,10 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { let poller = Poller::spawn( args.mgmt_addr.clone(), args.mgmt_port, - Box::new(move |st| { + args.web_port, + Box::new(move |st, console_up| { *app().status.lock().unwrap() = st; + app().web_console.store(console_up, Ordering::SeqCst); let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _); // SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails // harmlessly with an error we ignore. @@ -308,7 +305,7 @@ fn show_menu(hwnd: HWND) { }; add(IDM_HEADER, &status.headline(), true); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); - if app().web_console { + 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() { @@ -418,7 +415,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) match (lparam.0 as u32) & 0xffff { WM_CONTEXTMENU => show_menu(hwnd), x if x == NIN_SELECT || x == NIN_KEYSELECT => { - if app.web_console { + if app.web_console.load(Ordering::SeqCst) { open_web_console(hwnd); } else { show_menu(hwnd);