fix(tray): live-probe the web console instead of sniffing the install layout
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Successful in 1m31s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m35s
android / android (push) Successful in 4m45s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m0s
release / apple (push) Successful in 7m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Successful in 1m31s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m35s
android / android (push) Successful in 4m45s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m0s
release / apple (push) Successful in 7m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
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:<web
port>/ each cycle (any HTTP response = up, transport failure = down) and the
menu follows live on both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,13 @@ use std::sync::{Arc, OnceLock};
|
|||||||
|
|
||||||
use crate::status::{self, Poller, TrayStatus};
|
use crate::status::{self, Poller, TrayStatus};
|
||||||
|
|
||||||
/// The tray's D-Bus/menu model. `status` is the only mutable state; the poller rewrites it via
|
/// The tray's D-Bus/menu model. `status` + `web_console` are the mutable state; the poller
|
||||||
/// `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu).
|
/// rewrites them via `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu).
|
||||||
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
|
||||||
|
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||||
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.
|
||||||
@@ -192,28 +194,6 @@ fn host_present() -> bool {
|
|||||||
.is_ok_and(|s| s.success())
|
.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).
|
/// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime).
|
||||||
fn acquire_instance_lock() -> Option<std::fs::File> {
|
fn acquire_instance_lock() -> Option<std::fs::File> {
|
||||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
@@ -247,7 +227,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
|||||||
let tray = HostTray {
|
let tray = HostTray {
|
||||||
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
|
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
|
||||||
web_port: args.web_port,
|
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(),
|
poller: poller_slot.clone(),
|
||||||
};
|
};
|
||||||
// Autostart races the desktop (the watcher may register after us) → be lenient and wait for
|
// 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(
|
let poller = Poller::spawn(
|
||||||
args.mgmt_addr.clone(),
|
args.mgmt_addr.clone(),
|
||||||
args.mgmt_port,
|
args.mgmt_port,
|
||||||
Box::new(move |st| {
|
args.web_port,
|
||||||
if update_handle
|
Box::new(move |st, console_up| {
|
||||||
.update(|t: &mut HostTray| t.status = st)
|
let updated = update_handle.update(|t: &mut HostTray| {
|
||||||
.is_none()
|
t.status = st;
|
||||||
{
|
t.web_console = console_up;
|
||||||
|
});
|
||||||
|
if updated.is_none() {
|
||||||
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
|
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -118,11 +118,15 @@ struct Shared {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Poller {
|
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(
|
pub fn spawn(
|
||||||
mgmt_addr: String,
|
mgmt_addr: String,
|
||||||
mgmt_port: u16,
|
mgmt_port: u16,
|
||||||
on_change: Box<dyn Fn(TrayStatus) + Send>,
|
web_port: u16,
|
||||||
|
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||||
) -> Poller {
|
) -> Poller {
|
||||||
let shared = Arc::new(Shared {
|
let shared = Arc::new(Shared {
|
||||||
poked: Mutex::new(false),
|
poked: Mutex::new(false),
|
||||||
@@ -131,7 +135,7 @@ impl Poller {
|
|||||||
let thread_shared = shared.clone();
|
let thread_shared = shared.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("status-poll".into())
|
.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");
|
.expect("spawn status-poll thread");
|
||||||
Poller { shared }
|
Poller { shared }
|
||||||
}
|
}
|
||||||
@@ -147,7 +151,8 @@ fn poll_loop(
|
|||||||
shared: &Shared,
|
shared: &Shared,
|
||||||
mgmt_addr: &str,
|
mgmt_addr: &str,
|
||||||
mgmt_port: u16,
|
mgmt_port: u16,
|
||||||
on_change: Box<dyn Fn(TrayStatus) + Send>,
|
web_port: u16,
|
||||||
|
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||||
) {
|
) {
|
||||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||||
let url = if mgmt_addr.contains(':') {
|
let url = if mgmt_addr.contains(':') {
|
||||||
@@ -155,8 +160,9 @@ fn poll_loop(
|
|||||||
} else {
|
} else {
|
||||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
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 agent = agent(load_pin());
|
||||||
let mut last: Option<TrayStatus> = None;
|
let mut last: Option<(TrayStatus, bool)> = None;
|
||||||
// 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;
|
||||||
@@ -176,12 +182,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);
|
||||||
if last.as_ref() != Some(&status) {
|
let console_up = probe_console(&agent, &console_url);
|
||||||
on_change(status.clone());
|
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||||
last = Some(status);
|
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.
|
// 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),
|
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
|
||||||
_ => Duration::from_secs(3),
|
_ => 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) ──────────────────────────────────────────────────────────────
|
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! left admin-gated rather than DACL-opened to every local user.
|
//! left admin-gated rather than DACL-opened to every local user.
|
||||||
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
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 std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use windows::core::{w, PCWSTR};
|
use windows::core::{w, PCWSTR};
|
||||||
@@ -73,8 +73,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 installer bundled the web console (detected via `{app}\web\web-run.cmd`).
|
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||||
web_console: bool,
|
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||||
|
web_console: AtomicBool,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +126,10 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let exe_dir = std::env::current_exe()
|
let host_exe = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
|
||||||
let host_exe = exe_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.join("punktfunk-host.exe"))
|
|
||||||
.filter(|p| p.exists());
|
.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.
|
// SAFETY: RegisterWindowMessageW with a static nul-terminated literal.
|
||||||
let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) };
|
let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) };
|
||||||
@@ -144,7 +139,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
|||||||
poller: OnceLock::new(),
|
poller: OnceLock::new(),
|
||||||
taskbar_created,
|
taskbar_created,
|
||||||
host_exe,
|
host_exe,
|
||||||
web_console,
|
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
|
||||||
web_port: args.web_port,
|
web_port: args.web_port,
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
@@ -200,8 +195,10 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
|||||||
let poller = Poller::spawn(
|
let poller = Poller::spawn(
|
||||||
args.mgmt_addr.clone(),
|
args.mgmt_addr.clone(),
|
||||||
args.mgmt_port,
|
args.mgmt_port,
|
||||||
Box::new(move |st| {
|
args.web_port,
|
||||||
|
Box::new(move |st, console_up| {
|
||||||
*app().status.lock().unwrap() = st;
|
*app().status.lock().unwrap() = st;
|
||||||
|
app().web_console.store(console_up, Ordering::SeqCst);
|
||||||
let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _);
|
let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _);
|
||||||
// SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails
|
// SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails
|
||||||
// harmlessly with an error we ignore.
|
// harmlessly with an error we ignore.
|
||||||
@@ -308,7 +305,7 @@ 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());
|
||||||
if app().web_console {
|
if app().web_console.load(Ordering::SeqCst) {
|
||||||
add(IDM_OPEN_WEB, "Open web console", false);
|
add(IDM_OPEN_WEB, "Open web console", 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() {
|
||||||
@@ -418,7 +415,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
|||||||
match (lparam.0 as u32) & 0xffff {
|
match (lparam.0 as u32) & 0xffff {
|
||||||
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 {
|
if app.web_console.load(Ordering::SeqCst) {
|
||||||
open_web_console(hwnd);
|
open_web_console(hwnd);
|
||||||
} else {
|
} else {
|
||||||
show_menu(hwnd);
|
show_menu(hwnd);
|
||||||
|
|||||||
Reference in New Issue
Block a user