feat(tray): system-tray status icon for the host (Windows + Linux)
New crates/punktfunk-tray — a small per-user companion showing the host service state at a glance (running / stopped / starting / degraded / failed + the live session in the tooltip) with one-click actions: open web console, approve a pending pairing request, start/stop/restart, open logs. No more digging through logs to learn whether the service came back after a reboot or an update. Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can never fake Running), then the new loopback-only unauthenticated GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth). Windows: windows_subsystem binary (a console exe in the Run key would flash a terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single instance, TaskbarCreated re-add, --quit for the uninstaller; service actions elevate per click via ShellExecuteW "runas" onto the new `punktfunk-host service restart` (stop → wait Stopped → start). Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit), /etc/xdg/autostart entry whose --autostart self-gates to actual host users. Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons. Live-validated: Linux on the headless KDE session (SNI registration, state transitions, menu-driven start, dbusmenu layout); Windows on the RTX box (session-1 launch with no NIM_ADD failure, single instance, --quit, restart round-trip, summary loopback-200/LAN-401). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//! Linux tray: a StatusNotifierItem (ksni/zbus) fed by the status poller. The host runs as the
|
||||
//! systemd **user** unit `punktfunk-host.service`, so start/stop/restart are plain
|
||||
//! `systemctl --user` calls — no polkit, no elevation. KDE (the project's primary Linux desktop)
|
||||
//! renders SNI natively; GNOME needs the AppIndicator extension (without it the icon is invisible
|
||||
//! — `--autostart` exits silently rather than erroring at every login).
|
||||
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
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).
|
||||
struct HostTray {
|
||||
status: TrayStatus,
|
||||
web_port: u16,
|
||||
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.
|
||||
poller: Arc<OnceLock<Poller>>,
|
||||
}
|
||||
|
||||
impl HostTray {
|
||||
fn systemctl(&self, verb: &str) {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", verb, status::UNIT_NAME])
|
||||
.status();
|
||||
if let Some(p) = self.poller.get() {
|
||||
p.poke();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_console(&self) {
|
||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
}
|
||||
|
||||
impl ksni::Tray for HostTray {
|
||||
fn id(&self) -> String {
|
||||
"punktfunk-tray".into()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
"punktfunk host".into()
|
||||
}
|
||||
|
||||
fn status(&self) -> ksni::Status {
|
||||
match &self.status {
|
||||
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
|
||||
s if s.pairing_attention() => ksni::Status::NeedsAttention,
|
||||
_ => ksni::Status::Active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hicolor theme names (installed by the packages); `icon_pixmap` below is the fallback so a
|
||||
/// `cargo run` from the repo shows an icon too.
|
||||
fn icon_name(&self) -> String {
|
||||
match &self.status {
|
||||
TrayStatus::Running(_) if self.status.is_streaming() => {
|
||||
"punktfunk-tray-streaming".into()
|
||||
}
|
||||
TrayStatus::Running(_) => "punktfunk-tray".into(),
|
||||
TrayStatus::Starting | TrayStatus::Degraded => "punktfunk-tray-degraded".into(),
|
||||
TrayStatus::Error(_) => "punktfunk-tray-error".into(),
|
||||
TrayStatus::Stopped | TrayStatus::NotInstalled => "punktfunk-tray-stopped".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||
// Same dot palette as scripts/gen-tray-icons.py.
|
||||
let rgb = match &self.status {
|
||||
TrayStatus::Running(_) if self.status.is_streaming() => (0xb4, 0x4c, 0xf0), // violet
|
||||
TrayStatus::Running(_) => (0x2e, 0xcc, 0x71), // green
|
||||
TrayStatus::Starting | TrayStatus::Degraded => (0xf0, 0xa0, 0x30), // amber
|
||||
TrayStatus::Error(_) => (0xe7, 0x4c, 0x3c), // red
|
||||
TrayStatus::Stopped | TrayStatus::NotInstalled => (0x8a, 0x8a, 0x8a), // gray
|
||||
};
|
||||
vec![dot_icon(22, rgb), dot_icon(48, rgb)]
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ksni::ToolTip {
|
||||
ksni::ToolTip {
|
||||
title: self.status.headline(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
use ksni::menu::*;
|
||||
let running = matches!(
|
||||
self.status,
|
||||
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
|
||||
);
|
||||
let startable = matches!(
|
||||
self.status,
|
||||
TrayStatus::Stopped | TrayStatus::Error(_) | TrayStatus::NotInstalled
|
||||
);
|
||||
vec![
|
||||
StandardItem {
|
||||
label: self.status.headline(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Open web console".into(),
|
||||
visible: self.web_console,
|
||||
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()),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Start host".into(),
|
||||
visible: startable && !matches!(self.status, TrayStatus::NotInstalled),
|
||||
activate: Box::new(|t: &mut Self| t.systemctl("start")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Stop host".into(),
|
||||
visible: running,
|
||||
activate: Box::new(|t: &mut Self| t.systemctl("stop")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Restart host".into(),
|
||||
visible: running || matches!(self.status, TrayStatus::Error(_)),
|
||||
activate: Box::new(|t: &mut Self| t.systemctl("restart")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Exit tray".into(),
|
||||
activate: Box::new(|_: &mut Self| std::process::exit(0)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Keep waiting when the watcher drops (plasmashell restart, GNOME shell reload) — the item
|
||||
/// re-registers when it returns. Only `--autostart` runs get here with SNI truly absent, and
|
||||
/// lingering invisibly is the documented trade-off (see `assume_sni_available` below).
|
||||
fn watcher_offline(&self, _reason: ksni::OfflineReason) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat antialiased status dot — the pixmap fallback when the hicolor icons aren't installed
|
||||
/// (dev runs from `target/`). ARGB32, network byte order (per the SNI spec).
|
||||
fn dot_icon(size: i32, (r, g, b): (u8, u8, u8)) -> ksni::Icon {
|
||||
let mut data = Vec::with_capacity((size * size * 4) as usize);
|
||||
let center = (size as f32 - 1.0) / 2.0;
|
||||
let radius = size as f32 * 0.38;
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let d = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt();
|
||||
// 1 px antialiasing ramp at the rim.
|
||||
let alpha = ((radius - d + 0.5).clamp(0.0, 1.0) * 255.0) as u8;
|
||||
data.extend_from_slice(&[alpha, r, g, b]);
|
||||
}
|
||||
}
|
||||
ksni::Icon {
|
||||
width: size,
|
||||
height: size,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this user's box run (or intend to run) a punktfunk host? Gates `--autostart` so the
|
||||
/// packaged autostart entry doesn't put an icon in every desktop user's tray.
|
||||
fn host_present() -> bool {
|
||||
if status::punktfunk_config_dir().is_some_and(|d| d.exists()) {
|
||||
return true;
|
||||
}
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["--user", "--quiet", "is-enabled", status::UNIT_NAME])
|
||||
.status()
|
||||
.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<std::fs::File> {
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(dir.join("punktfunk-tray.lock"))
|
||||
.ok()?;
|
||||
// SAFETY: `file` is an open, owned fd for the duration of the call; LOCK_NB makes this a
|
||||
// non-blocking advisory lock attempt with no other side effects.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
(rc == 0).then_some(file)
|
||||
}
|
||||
|
||||
pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||
if args.quit {
|
||||
// Windows-only convenience for the uninstaller; nothing to do here.
|
||||
return Ok(());
|
||||
}
|
||||
if args.autostart && !host_present() {
|
||||
return Ok(()); // not a host box — stay out of this user's tray
|
||||
}
|
||||
let Some(_lock) = acquire_instance_lock() else {
|
||||
return Ok(()); // another instance already runs in this session
|
||||
};
|
||||
|
||||
let poller_slot = Arc::new(OnceLock::new());
|
||||
let tray = HostTray {
|
||||
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
|
||||
web_port: args.web_port,
|
||||
web_console: web_console_installed(),
|
||||
poller: poller_slot.clone(),
|
||||
};
|
||||
// Autostart races the desktop (the watcher may register after us) → be lenient and wait for
|
||||
// it. A manual launch should fail loudly instead (e.g. GNOME without the AppIndicator
|
||||
// extension) so the user learns why there is no icon.
|
||||
use ksni::blocking::TrayMethods;
|
||||
let handle = match tray.assume_sni_available(args.autostart).spawn() {
|
||||
Ok(h) => h,
|
||||
Err(e) if args.autostart => {
|
||||
eprintln!("punktfunk-tray: no StatusNotifier host ({e}); exiting");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => anyhow::bail!(
|
||||
"no StatusNotifier tray available ({e}) — on GNOME, install the AppIndicator extension"
|
||||
),
|
||||
};
|
||||
|
||||
let dead = Arc::new(AtomicBool::new(false));
|
||||
let dead_flag = dead.clone();
|
||||
let update_handle = handle.clone();
|
||||
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()
|
||||
{
|
||||
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
|
||||
}
|
||||
}),
|
||||
);
|
||||
let _ = poller_slot.set(poller);
|
||||
|
||||
// The SNI service runs on its own thread; park here until it dies (shell logout etc.).
|
||||
while !dead.load(Ordering::SeqCst) && !handle.is_closed() {
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! punktfunk-tray — a small per-user system-tray companion for the punktfunk host service.
|
||||
//!
|
||||
//! Shows at a glance whether the host is running / stopped / degraded / failed (no more digging
|
||||
//! through logs after a reboot or an update), and offers the common one-click actions: open the
|
||||
//! web console, start/stop/restart the service (UAC-elevated per action on Windows,
|
||||
//! `systemctl --user` on Linux), review a pending pairing request, exit.
|
||||
//!
|
||||
//! Status comes from two sources, service manager FIRST (a fake listener on the mgmt port can
|
||||
//! never make a stopped service look running): the SCM / systemd user unit for the process state,
|
||||
//! then the host's loopback-only unauthenticated `GET /api/v1/local/summary` for the streaming
|
||||
//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal
|
||||
//! window at every sign-in.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
mod status;
|
||||
#[cfg(windows)]
|
||||
mod win;
|
||||
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
|
||||
/// autostart command line instead.
|
||||
pub struct Args {
|
||||
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
|
||||
pub quit: bool,
|
||||
/// Launched from the desktop autostart entry: exit silently when this box doesn't run a host
|
||||
/// (Linux; the package installs the autostart file for every desktop user).
|
||||
pub autostart: bool,
|
||||
/// Management API address to poll (loopback only; the summary route rejects anything else).
|
||||
pub mgmt_addr: String,
|
||||
pub mgmt_port: u16,
|
||||
/// Web console port for the "Open web console" action.
|
||||
pub web_port: u16,
|
||||
}
|
||||
|
||||
impl Default for Args {
|
||||
fn default() -> Self {
|
||||
Args {
|
||||
quit: false,
|
||||
autostart: false,
|
||||
mgmt_addr: "127.0.0.1".into(),
|
||||
mgmt_port: 47990,
|
||||
web_port: 47992,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> anyhow::Result<Args> {
|
||||
let mut args = Args::default();
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
let mut value = |flag: &str| {
|
||||
it.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
|
||||
};
|
||||
match a.as_str() {
|
||||
"--quit" => args.quit = true,
|
||||
"--autostart" => args.autostart = true,
|
||||
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
|
||||
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
|
||||
"--web-port" => args.web_port = value("--web-port")?.parse()?,
|
||||
"--version" | "-V" => {
|
||||
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"unknown argument '{other}'\n\nUSAGE:\n punktfunk-tray [--autostart] [--quit] \
|
||||
[--mgmt-addr <IP>] [--mgmt-port <N>] [--web-port <N>]"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = parse_args()?;
|
||||
run(args)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run(args: Args) -> anyhow::Result<()> {
|
||||
win::run(args)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run(args: Args) -> anyhow::Result<()> {
|
||||
linux::run(args)
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
fn run(_args: Args) -> anyhow::Result<()> {
|
||||
// Workspace-stub build (macOS CI etc.) — the tray ships on Windows and Linux only.
|
||||
anyhow::bail!("punktfunk-tray supports Windows and Linux hosts only")
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Host status model + the poller thread feeding the platform tray implementations.
|
||||
//!
|
||||
//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides
|
||||
//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is
|
||||
//! down can never make the tray say Running. Only when the service manager reports Running does
|
||||
//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail.
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What the service manager reports for the host service.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ServiceState {
|
||||
NotInstalled,
|
||||
Stopped,
|
||||
StartPending,
|
||||
StopPending,
|
||||
Running,
|
||||
/// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback
|
||||
/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer
|
||||
/// host can grow the summary without breaking an older tray.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
|
||||
pub struct Summary {
|
||||
pub version: String,
|
||||
pub video_streaming: bool,
|
||||
pub audio_streaming: bool,
|
||||
pub session: Option<SessionInfo>,
|
||||
pub paired_clients: u32,
|
||||
pub native_paired_clients: u32,
|
||||
pub pin_pending: bool,
|
||||
pub pending_approvals: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
}
|
||||
|
||||
/// What the icon shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TrayStatus {
|
||||
NotInstalled,
|
||||
Stopped,
|
||||
/// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]).
|
||||
Starting,
|
||||
Running(Summary),
|
||||
/// Service running but the summary unreachable past the grace period — amber, not red: a
|
||||
/// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate.
|
||||
Degraded,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl TrayStatus {
|
||||
/// One-line headline for the tooltip / the disabled menu header.
|
||||
pub fn headline(&self) -> String {
|
||||
match self {
|
||||
TrayStatus::NotInstalled => "punktfunk host — not installed".into(),
|
||||
TrayStatus::Stopped => "punktfunk host — stopped".into(),
|
||||
TrayStatus::Starting => "punktfunk host — starting…".into(),
|
||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||
TrayStatus::Running(s) => match (&s.session, s.video_streaming) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
),
|
||||
(_, true) => format!("punktfunk host {} — streaming", s.version),
|
||||
_ => format!("punktfunk host {} — idle", s.version),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||
}
|
||||
|
||||
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||
pub fn pairing_attention(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a running service may leave the summary unreachable before Starting turns Degraded.
|
||||
/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like
|
||||
/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red.
|
||||
pub const START_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third.
|
||||
pub fn map_status(svc: &ServiceState, summary: Option<Summary>, grace_expired: bool) -> TrayStatus {
|
||||
match svc {
|
||||
ServiceState::NotInstalled => TrayStatus::NotInstalled,
|
||||
ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped,
|
||||
ServiceState::StartPending => TrayStatus::Starting,
|
||||
ServiceState::Failed(e) => TrayStatus::Error(e.clone()),
|
||||
ServiceState::Running => match summary {
|
||||
Some(s) => TrayStatus::Running(s),
|
||||
None if !grace_expired => TrayStatus::Starting,
|
||||
None => TrayStatus::Degraded,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Poller ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct Poller {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
poked: Mutex<bool>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
/// Spawn the poll thread; `on_change` fires (from that thread) whenever the status changes.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus) + Send>,
|
||||
) -> Poller {
|
||||
let shared = Arc::new(Shared {
|
||||
poked: Mutex::new(false),
|
||||
cv: Condvar::new(),
|
||||
});
|
||||
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))
|
||||
.expect("spawn status-poll thread");
|
||||
Poller { shared }
|
||||
}
|
||||
|
||||
/// Force an immediate re-poll (right after a start/stop/restart menu action).
|
||||
pub fn poke(&self) {
|
||||
*self.shared.poked.lock().unwrap() = true;
|
||||
self.shared.cv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_loop(
|
||||
shared: &Shared,
|
||||
mgmt_addr: &str,
|
||||
mgmt_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus) + Send>,
|
||||
) {
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
let url = if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||
};
|
||||
let agent = agent(load_pin());
|
||||
let mut last: Option<TrayStatus> = 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<Instant> = None;
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &url);
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
None => {}
|
||||
}
|
||||
s
|
||||
} else {
|
||||
unreachable_since = None;
|
||||
None
|
||||
};
|
||||
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);
|
||||
}
|
||||
// 3 s while there is anything to watch; back off when the box just doesn't run a host.
|
||||
let cadence = match last {
|
||||
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
|
||||
_ => Duration::from_secs(3),
|
||||
};
|
||||
let mut poked = shared.poked.lock().unwrap();
|
||||
if !*poked {
|
||||
(poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap();
|
||||
}
|
||||
*poked = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On
|
||||
/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` =
|
||||
/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials,
|
||||
/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so
|
||||
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
|
||||
fn load_pin() -> Option<[u8; 32]> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use sha2::Digest;
|
||||
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
|
||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
|
||||
Some(sha2::Sha256::digest(der.as_ref()).into())
|
||||
}
|
||||
|
||||
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
|
||||
/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux).
|
||||
/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway.
|
||||
pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
|
||||
if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") {
|
||||
if !d.is_empty() {
|
||||
return Some(std::path::PathBuf::from(d));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
if !x.is_empty() {
|
||||
return Some(std::path::PathBuf::from(x).join("punktfunk"));
|
||||
}
|
||||
}
|
||||
std::env::var_os("HOME").map(|h| {
|
||||
std::path::PathBuf::from(h)
|
||||
.join(".config")
|
||||
.join("punktfunk")
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
}
|
||||
|
||||
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
|
||||
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
|
||||
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let cfg = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("rustls default protocol versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }))
|
||||
.with_no_client_auth();
|
||||
ureq::AgentBuilder::new()
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(2))
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake
|
||||
/// signatures are still verified for real — CertificateVerify proves the peer holds the key.
|
||||
#[derive(Debug)]
|
||||
struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
use sha2::Digest;
|
||||
if let Some(expected) = self.pin {
|
||||
let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into();
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
|
||||
#[cfg(windows)]
|
||||
pub const SERVICE_NAME: &str = "PunktfunkHost";
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn probe_service() -> ServiceState {
|
||||
use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
// CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall
|
||||
// (delete + create) invalidates old handles, and this picks the new service up within a poll.
|
||||
let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
else {
|
||||
return ServiceState::NotInstalled;
|
||||
};
|
||||
let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else {
|
||||
return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al.
|
||||
};
|
||||
let Ok(status) = svc.query_status() else {
|
||||
return ServiceState::NotInstalled;
|
||||
};
|
||||
match status.current_state {
|
||||
Scm::StartPending => ServiceState::StartPending,
|
||||
Scm::StopPending => ServiceState::StopPending,
|
||||
Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => {
|
||||
ServiceState::Running
|
||||
}
|
||||
Scm::Stopped => match status.exit_code {
|
||||
// 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no —
|
||||
// "no attempts to start have been made"): both are an ordinary Stopped, not a failure.
|
||||
ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped,
|
||||
ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")),
|
||||
ServiceExitCode::ServiceSpecific(code) => {
|
||||
ServiceState::Failed(format!("service error {code}"))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const UNIT_NAME: &str = "punktfunk-host.service";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn probe_service() -> ServiceState {
|
||||
// `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely
|
||||
// on the exit code.
|
||||
let Ok(out) = std::process::Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"show",
|
||||
UNIT_NAME,
|
||||
"--property=LoadState,ActiveState,SubState",
|
||||
])
|
||||
.output()
|
||||
else {
|
||||
return ServiceState::NotInstalled; // no systemctl → nothing to watch
|
||||
};
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let prop = |key: &str| {
|
||||
text.lines()
|
||||
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
if prop("LoadState") == "not-found" {
|
||||
return ServiceState::NotInstalled;
|
||||
}
|
||||
match prop("ActiveState").as_str() {
|
||||
"active" | "reloading" => ServiceState::Running,
|
||||
"activating" => ServiceState::StartPending,
|
||||
"deactivating" => ServiceState::StopPending,
|
||||
"failed" => ServiceState::Failed(prop("SubState")),
|
||||
_ => ServiceState::Stopped, // "inactive" and anything new
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn summary(streaming: bool) -> Summary {
|
||||
Summary {
|
||||
version: "0.5.1".into(),
|
||||
video_streaming: streaming,
|
||||
audio_streaming: streaming,
|
||||
session: streaming.then_some(SessionInfo {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
fps: 120,
|
||||
}),
|
||||
paired_clients: 1,
|
||||
native_paired_clients: 2,
|
||||
pin_pending: false,
|
||||
pending_approvals: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full (service state × summary × grace) table.
|
||||
#[test]
|
||||
fn status_mapping_table() {
|
||||
use ServiceState as S;
|
||||
use TrayStatus as T;
|
||||
let cases: Vec<(S, Option<Summary>, bool, T)> = vec![
|
||||
(S::NotInstalled, None, false, T::NotInstalled),
|
||||
(S::Stopped, None, false, T::Stopped),
|
||||
(S::StopPending, None, false, T::Stopped),
|
||||
(S::StartPending, None, false, T::Starting),
|
||||
(
|
||||
S::Failed("code 3".into()),
|
||||
None,
|
||||
false,
|
||||
T::Error("code 3".into()),
|
||||
),
|
||||
// Running + summary → Running regardless of grace.
|
||||
(
|
||||
S::Running,
|
||||
Some(summary(false)),
|
||||
true,
|
||||
T::Running(summary(false)),
|
||||
),
|
||||
// Running + unreachable: Starting within grace, Degraded past it.
|
||||
(S::Running, None, false, T::Starting),
|
||||
(S::Running, None, true, T::Degraded),
|
||||
// A summary while the SCM says Stopped is impossible by construction (the poller only
|
||||
// fetches when Running) — but the mapping must still trust the service manager.
|
||||
(S::Stopped, Some(summary(true)), false, T::Stopped),
|
||||
];
|
||||
for (svc, sum, grace, want) in cases {
|
||||
assert_eq!(
|
||||
map_status(&svc, sum.clone(), grace),
|
||||
want,
|
||||
"{svc:?} {sum:?} grace={grace}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headline_shows_session_and_reason() {
|
||||
assert_eq!(
|
||||
TrayStatus::Running(summary(true)).headline(),
|
||||
"punktfunk host 0.5.1 — streaming 2560×1440@120"
|
||||
);
|
||||
assert_eq!(
|
||||
TrayStatus::Running(summary(false)).headline(),
|
||||
"punktfunk host 0.5.1 — idle"
|
||||
);
|
||||
assert!(TrayStatus::Error("exit code 3".into())
|
||||
.headline()
|
||||
.contains("exit code 3"));
|
||||
assert!(TrayStatus::Degraded
|
||||
.headline()
|
||||
.contains("status unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_attention_flags() {
|
||||
let mut s = summary(false);
|
||||
assert!(!TrayStatus::Running(s.clone()).pairing_attention());
|
||||
s.pending_approvals = 1;
|
||||
assert!(TrayStatus::Running(s.clone()).pairing_attention());
|
||||
s.pending_approvals = 0;
|
||||
s.pin_pending = true;
|
||||
assert!(TrayStatus::Running(s).pairing_attention());
|
||||
assert!(!TrayStatus::Degraded.pairing_attention());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Windows tray: a hidden top-level window + `Shell_NotifyIconW`, fed by the status poller.
|
||||
//!
|
||||
//! The host service (`PunktfunkHost`, LocalSystem) supervises from session 0 and its `serve`
|
||||
//! child runs as SYSTEM — neither can own a per-user tray icon, so this is a separate small
|
||||
//! process the installer puts in the HKLM `Run` key (one instance per interactive session,
|
||||
//! enforced by a `Local\` mutex). Start/Stop/Restart open one UAC consent prompt each
|
||||
//! (`ShellExecuteW "runas"` on `punktfunk-host.exe service …`) — service control is deliberately
|
||||
//! 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::{Mutex, OnceLock};
|
||||
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{
|
||||
GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
|
||||
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
|
||||
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
|
||||
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
|
||||
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
|
||||
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
|
||||
WS_OVERLAPPED,
|
||||
};
|
||||
|
||||
use crate::status::{Poller, TrayStatus};
|
||||
|
||||
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
|
||||
/// exports only NIN_SELECT.
|
||||
const NIN_KEYSELECT: u32 = NIN_SELECT | 0x1;
|
||||
|
||||
/// Posted by the poller thread when the status changed (never touch TLS on the UI thread).
|
||||
const WMAPP_STATUS: u32 = WM_APP + 2;
|
||||
/// The notify-icon callback message (NOTIFYICON_VERSION_4 semantics).
|
||||
const WMAPP_NOTIFYCALLBACK: u32 = WM_APP + 1;
|
||||
|
||||
// Menu command ids (WM_COMMAND LOWORD(wParam)).
|
||||
const IDM_HEADER: usize = 0x0100; // disabled status line
|
||||
const IDM_OPEN_WEB: usize = 0x0101;
|
||||
const IDM_START: usize = 0x0102;
|
||||
const IDM_STOP: usize = 0x0103;
|
||||
const IDM_RESTART: usize = 0x0104;
|
||||
const IDM_LOGS: usize = 0x0105;
|
||||
const IDM_EXIT: usize = 0x0106;
|
||||
const IDM_PAIRING: usize = 0x0107;
|
||||
|
||||
/// Icon resource ordinals (embedded by build.rs).
|
||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||
match status {
|
||||
TrayStatus::Running(_) if status.is_streaming() => 5,
|
||||
TrayStatus::Running(_) => 2,
|
||||
TrayStatus::Stopped | TrayStatus::NotInstalled => 3,
|
||||
TrayStatus::Error(_) => 4,
|
||||
TrayStatus::Starting | TrayStatus::Degraded => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Global tray state — a tray has exactly one window and one wndproc, which cannot carry a
|
||||
/// closure environment, so the state lives in a `OnceLock` set before window creation.
|
||||
struct App {
|
||||
hwnd: AtomicIsize,
|
||||
status: Mutex<TrayStatus>,
|
||||
poller: OnceLock<Poller>,
|
||||
/// `TaskbarCreated` broadcast id — Explorer restarted, re-add the icon.
|
||||
taskbar_created: u32,
|
||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||
host_exe: Option<std::path::PathBuf>,
|
||||
/// The installer bundled the web console (detected via `{app}\web\web-run.cmd`).
|
||||
web_console: bool,
|
||||
web_port: u16,
|
||||
}
|
||||
|
||||
static APP: OnceLock<App> = OnceLock::new();
|
||||
|
||||
fn app() -> &'static App {
|
||||
APP.get().expect("APP initialized before window creation")
|
||||
}
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain([0]).collect()
|
||||
}
|
||||
|
||||
/// Best-effort log for a windows-subsystem process (no stderr): `%LOCALAPPDATA%\punktfunk\tray.log`.
|
||||
fn log(msg: &str) {
|
||||
let Some(base) = std::env::var_os("LOCALAPPDATA") else {
|
||||
return;
|
||||
};
|
||||
let dir = std::path::PathBuf::from(base).join("punktfunk");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(dir.join("tray.log"))
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||
let _ = args.autostart; // Linux-only flag, accepted for a uniform command line
|
||||
if args.quit {
|
||||
return quit_existing();
|
||||
}
|
||||
|
||||
// One tray per session: `Local\` scopes the mutex to this logon session, so fast-user-switched
|
||||
// sessions each keep their own icon. Handle deliberately leaked (held for the process life).
|
||||
// SAFETY: CreateMutexW with a valid nul-terminated name and no security attributes; the
|
||||
// returned handle is never closed (process-lifetime singleton guard).
|
||||
let already = unsafe {
|
||||
match CreateMutexW(None, false, w!("Local\\PunktfunkTray")) {
|
||||
Ok(_) => GetLastError() == ERROR_ALREADY_EXISTS,
|
||||
Err(_) => false, // can't tell — carry on rather than losing the icon
|
||||
}
|
||||
};
|
||||
if already {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_dir = 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"))
|
||||
.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")) };
|
||||
APP.set(App {
|
||||
hwnd: AtomicIsize::new(0),
|
||||
status: Mutex::new(TrayStatus::Stopped),
|
||||
poller: OnceLock::new(),
|
||||
taskbar_created,
|
||||
host_exe,
|
||||
web_console,
|
||||
web_port: args.web_port,
|
||||
})
|
||||
.ok()
|
||||
.expect("run() is called once");
|
||||
|
||||
// Hidden top-level window (NOT message-only — those never receive the TaskbarCreated
|
||||
// broadcast, which is how the icon survives an Explorer restart).
|
||||
// SAFETY: standard window-class registration + creation; the class name literal outlives the
|
||||
// call, wndproc is a valid extern "system" fn, and the window is created on this thread which
|
||||
// then runs the message loop.
|
||||
let hwnd = unsafe {
|
||||
let hinstance = GetModuleHandleW(None)?;
|
||||
let class = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinstance.into(),
|
||||
lpszClassName: w!("PunktfunkTrayWindow"),
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassW(&class) == 0 {
|
||||
anyhow::bail!("RegisterClassW failed: {:?}", GetLastError());
|
||||
}
|
||||
CreateWindowExW(
|
||||
WINDOW_EX_STYLE(0),
|
||||
w!("PunktfunkTrayWindow"),
|
||||
w!("punktfunk tray"),
|
||||
WS_OVERLAPPED,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
Some(hinstance.into()),
|
||||
None,
|
||||
)?
|
||||
};
|
||||
app().hwnd.store(hwnd.0 as isize, Ordering::SeqCst);
|
||||
|
||||
// First NIM_ADD retried across the logon race (the taskbar may not exist yet at sign-in).
|
||||
let mut added = false;
|
||||
for _ in 0..10 {
|
||||
if update_icon(hwnd, true) {
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if !added {
|
||||
log("Shell_NotifyIconW(NIM_ADD) kept failing — no taskbar?");
|
||||
}
|
||||
|
||||
// The poller owns all network/SCM I/O; it only posts a message here.
|
||||
let poller = Poller::spawn(
|
||||
args.mgmt_addr.clone(),
|
||||
args.mgmt_port,
|
||||
Box::new(move |st| {
|
||||
*app().status.lock().unwrap() = st;
|
||||
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.
|
||||
unsafe {
|
||||
let _ = PostMessageW(Some(hwnd), WMAPP_STATUS, WPARAM(0), LPARAM(0));
|
||||
}
|
||||
}),
|
||||
);
|
||||
let _ = app().poller.set(poller);
|
||||
|
||||
// SAFETY: classic message pump on the window's owning thread.
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while GetMessageW(&mut msg, None, 0, 0).into() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--quit`: ask a running instance (this session) to exit — used by the uninstaller before file
|
||||
/// deletion. High-IL callers may message a medium-IL window (UIPI blocks only low→high).
|
||||
fn quit_existing() -> anyhow::Result<()> {
|
||||
// SAFETY: FindWindowW/PostMessageW on a class-name literal; both fail harmlessly when no
|
||||
// instance is running.
|
||||
unsafe {
|
||||
if let Ok(hwnd) = FindWindowW(w!("PunktfunkTrayWindow"), PCWSTR::null()) {
|
||||
let _ = PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build/refresh the notify icon from the current status. Returns false when the shell rejected
|
||||
/// the call (no taskbar yet).
|
||||
fn update_icon(hwnd: HWND, add: bool) -> bool {
|
||||
let status = app().status.lock().unwrap().clone();
|
||||
let mut nid = NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: hwnd,
|
||||
uID: 1,
|
||||
uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
|
||||
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
|
||||
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
|
||||
nid.hIcon = unsafe {
|
||||
LoadIconW(
|
||||
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||
PCWSTR(icon_ordinal(&status) as usize as *const u16),
|
||||
)
|
||||
}
|
||||
.unwrap_or(HICON(std::ptr::null_mut()));
|
||||
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
|
||||
let tip = to_wide(&status.headline());
|
||||
let n = tip.len().min(nid.szTip.len() - 1);
|
||||
nid.szTip[..n].copy_from_slice(&tip[..n]);
|
||||
|
||||
// SAFETY: nid is fully initialized with a correct cbSize; NIM_* calls only read it.
|
||||
unsafe {
|
||||
if add {
|
||||
if !Shell_NotifyIconW(NIM_ADD, &nid).as_bool() {
|
||||
return false;
|
||||
}
|
||||
let mut v = nid;
|
||||
v.Anonymous.uVersion = NOTIFYICON_VERSION_4;
|
||||
let _ = Shell_NotifyIconW(NIM_SETVERSION, &v);
|
||||
true
|
||||
} else {
|
||||
if !Shell_NotifyIconW(NIM_MODIFY, &nid).as_bool() {
|
||||
// Icon vanished (Explorer crash we missed) — re-add.
|
||||
return update_icon(hwnd, true);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-click menu, rebuilt from the live status each time.
|
||||
fn show_menu(hwnd: HWND) {
|
||||
let status = app().status.lock().unwrap().clone();
|
||||
let running = matches!(
|
||||
status,
|
||||
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
|
||||
);
|
||||
let startable = matches!(status, TrayStatus::Stopped | TrayStatus::Error(_));
|
||||
let can_control = app().host_exe.is_some();
|
||||
|
||||
// SAFETY: menu handle created and destroyed here; AppendMenuW copies the item strings, whose
|
||||
// wide buffers outlive each call. TrackPopupMenuEx requires the foreground quirk handled
|
||||
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
|
||||
unsafe {
|
||||
let Ok(menu) = CreatePopupMenu() else { return };
|
||||
let add = |id: usize, text: &str, grayed: bool| {
|
||||
let wide = to_wide(text);
|
||||
let flags = if grayed {
|
||||
MF_STRING | MF_GRAYED
|
||||
} else {
|
||||
MF_STRING
|
||||
};
|
||||
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
|
||||
};
|
||||
add(IDM_HEADER, &status.headline(), true);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
if app().web_console {
|
||||
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());
|
||||
}
|
||||
if can_control {
|
||||
if startable {
|
||||
add(IDM_START, "Start host", false);
|
||||
}
|
||||
if running {
|
||||
add(IDM_STOP, "Stop host", false);
|
||||
add(IDM_RESTART, "Restart host", false);
|
||||
} else if matches!(status, TrayStatus::Error(_)) {
|
||||
add(IDM_RESTART, "Restart host", false);
|
||||
}
|
||||
}
|
||||
add(IDM_LOGS, "Open logs folder", false);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
add(IDM_EXIT, "Exit tray", false);
|
||||
|
||||
let mut pt = Default::default();
|
||||
let _ = GetCursorPos(&mut pt);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
let _ = TrackPopupMenuEx(
|
||||
menu,
|
||||
(TPM_RIGHTBUTTON | TPM_BOTTOMALIGN).0,
|
||||
pt.x,
|
||||
pt.y,
|
||||
hwnd,
|
||||
None,
|
||||
);
|
||||
let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0));
|
||||
let _ = DestroyMenu(menu);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShellExecuteW` "open" on a URL / folder.
|
||||
fn shell_open(hwnd: HWND, target: &str) {
|
||||
let wide = to_wide(target);
|
||||
// SAFETY: all strings nul-terminated and live across the call.
|
||||
unsafe {
|
||||
ShellExecuteW(
|
||||
Some(hwnd),
|
||||
w!("open"),
|
||||
PCWSTR(wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
SW_SHOWNORMAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One UAC prompt per service action: relaunch the host exe elevated with `service <verb>`.
|
||||
/// A declined prompt (ERROR_CANCELLED) is deliberately ignored.
|
||||
fn elevate_service(hwnd: HWND, verb: &str) {
|
||||
let Some(exe) = app().host_exe.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let exe_w = to_wide(&exe.to_string_lossy());
|
||||
let params = to_wide(&format!("service {verb}"));
|
||||
// SAFETY: nul-terminated strings live across the call; "runas" spawns the elevated child
|
||||
// (hidden console — the tray re-polls for the outcome instead of scraping its output).
|
||||
unsafe {
|
||||
ShellExecuteW(
|
||||
Some(hwnd),
|
||||
w!("runas"),
|
||||
PCWSTR(exe_w.as_ptr()),
|
||||
PCWSTR(params.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
SW_HIDE,
|
||||
);
|
||||
}
|
||||
if let Some(p) = app().poller.get() {
|
||||
p.poke();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_web_console(hwnd: HWND) {
|
||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
||||
}
|
||||
|
||||
fn open_logs(hwnd: HWND) {
|
||||
let Some(base) = std::env::var_os("ProgramData") else {
|
||||
return;
|
||||
};
|
||||
let dir = std::path::PathBuf::from(base)
|
||||
.join("punktfunk")
|
||||
.join("logs");
|
||||
shell_open(hwnd, &dir.to_string_lossy());
|
||||
}
|
||||
|
||||
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
let Some(app) = APP.get() else {
|
||||
// SAFETY: pass-through for messages arriving before APP is set (CreateWindowExW sends
|
||||
// WM_NCCREATE/WM_CREATE synchronously — APP is set before that, but stay defensive).
|
||||
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
|
||||
};
|
||||
match msg {
|
||||
WMAPP_STATUS => {
|
||||
update_icon(hwnd, false);
|
||||
LRESULT(0)
|
||||
}
|
||||
WMAPP_NOTIFYCALLBACK => {
|
||||
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
|
||||
match (lparam.0 as u32) & 0xffff {
|
||||
WM_CONTEXTMENU => show_menu(hwnd),
|
||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||
if app.web_console {
|
||||
open_web_console(hwnd);
|
||||
} else {
|
||||
show_menu(hwnd);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
match (wparam.0) & 0xffff {
|
||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
||||
IDM_PAIRING => open_web_console(hwnd),
|
||||
IDM_START => elevate_service(hwnd, "start"),
|
||||
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||
IDM_LOGS => open_logs(hwnd),
|
||||
// SAFETY: DestroyWindow on the wndproc's own window/thread.
|
||||
IDM_EXIT => unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_CLOSE | WM_ENDSESSION => {
|
||||
// SAFETY: as above — triggers WM_DESTROY below.
|
||||
unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
let nid = NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: hwnd,
|
||||
uID: 1,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: minimal, correctly sized nid; NIM_DELETE only reads hWnd/uID.
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
m if m == app.taskbar_created => {
|
||||
// Explorer restarted — the icon is gone; add it back.
|
||||
update_icon(hwnd, true);
|
||||
LRESULT(0)
|
||||
}
|
||||
// SAFETY: default handling for everything else.
|
||||
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user