From 148eb69c584a9b41ccc879a6abc50d4ffa08f2a4 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Thu, 3 Sep 2026 21:09:44 +0700 Subject: [PATCH 01/12] refactor: own the status reply wire contract in hyprlay-core The status reply was built by one 18-field format! in the daemon and re-parsed by two hand-rolled readers (tray parse_status, gui brief_status); field order was load-bearing in three places. Now hyprlay-core::status::StatusFields owns the line in both directions: the daemon fills the struct and sends to_wire(), the tray and GUI read through parse_wire(), and the three liveness probes use is_status_line. Appending a field is a one-place edit, and readers survive it by construction (ADR-002's append-at-end convention made structural). The wire line is byte-identical to the old format! output - proven by the untouched tray/GUI/daemon characterization suite plus a new full-line pin (the first the reply ever had). Deliberate deviations from bug-for-bug, each unreachable from what the current daemon writes (displayed().len(), channel_name().unwrap_or("-"), ConnectionStatus::as_str): - participants=abc parses as 0; the old tray echoed the raw token - the tray summary shows the parsed count even when the participants token was empty (old: no count) - brief_status drops the trailing separator for an empty channel value Marker lookups require a leading space (" participants=" etc.), a slight tightening over the old find("participants=") so a channel name embedding a marker cannot be misread. Field types follow config.rs (u8 scale, i32 offsets) rather than the earlier sketch (u16/i16) to avoid lossy casts. ADR-002's Consequences now point the convention at StatusFields; docs/dev/code-layout.md gains the status.rs row. --- crates/hyprlay-core/src/lib.rs | 1 + crates/hyprlay-core/src/status.rs | 317 +++++++++++++++++++ docs/adr/002-overlay-layer-for-fullscreen.md | 2 +- docs/dev/code-layout.md | 1 + src/daemon/mod.rs | 39 +-- src/gui/daemon.rs | 5 +- src/gui/mod.rs | 36 +-- src/tray/menu.rs | 129 +++----- 8 files changed, 409 insertions(+), 121 deletions(-) create mode 100644 crates/hyprlay-core/src/status.rs diff --git a/crates/hyprlay-core/src/lib.rs b/crates/hyprlay-core/src/lib.rs index 9c8fa0c..280e76b 100644 --- a/crates/hyprlay-core/src/lib.rs +++ b/crates/hyprlay-core/src/lib.rs @@ -14,3 +14,4 @@ pub mod daemon_control; pub mod domain; pub mod platform; pub mod singleton; +pub mod status; diff --git a/crates/hyprlay-core/src/status.rs b/crates/hyprlay-core/src/status.rs new file mode 100644 index 0000000..62ede29 --- /dev/null +++ b/crates/hyprlay-core/src/status.rs @@ -0,0 +1,317 @@ +//! The `status` reply line: one source of truth for the byte-stable wire +//! contract shared by the daemon (writer) and the tray/GUI (readers). +//! +//! The daemon fills a [`StatusFields`] from live state and config and sends +//! [`to_wire`](StatusFields::to_wire) as the `status` reply; every front +//! reads replies back through [`parse_wire`](StatusFields::parse_wire). +//! Field order and spelling are the observable contract (CONTEXT.md: +//! replies are pinned byte-for-byte; ADR-002: new fields append at the end), +//! so both directions live in this one module and change together. +//! +//! Parsing stays lenient bug-for-bug: an unreadable number yields 0, a +//! missing trailing field (older daemon) yields its default, and the +//! `channel` value is sliced between its markers because channel names may +//! contain spaces. + +/// One parsed `status=` reply, one field per wire key. +#[derive(Debug, PartialEq, Eq)] +pub struct StatusFields { + /// Connection-status word, e.g. `connected`, `disconnected`, `off`. + pub status_word: String, + /// Voice-channel name; `-` when not connected. May contain spaces. + pub channel: String, + /// Number of roster rows the overlay displays. + pub participants: usize, + /// Corner word (`top-left` … `bottom-right`). + pub position: String, + /// Right-to-left layout flag. + pub rtl: bool, + /// Overlay visibility flag. + pub visible: bool, + /// Anchor-mode word (`auto`, `top`, `bottom`). + pub anchor: String, + /// Global scale in percent. + pub scale: u8, + /// Overall opacity in percent. + pub opacity: u8, + /// Horizontal distance in px from the anchored screen edge. + pub offset_x: i32, + /// Vertical distance in px from the anchored screen edge. + pub offset_y: i32, + /// Target output name; `None` renders as `active`, and parsing maps + /// the literal `active` back to `None` (the wire cannot tell them apart). + pub monitor: Option, + /// Auth backend label, e.g. `own-app`. + pub auth: String, + /// Overlay-layer flag from ADR-002. + pub show_on_fullscreen: bool, + /// Hover-dim flag from ADR-002. + pub dim_on_hover: bool, + /// Hover opacity in percent from ADR-002. + pub hover_opacity: u8, +} + +impl StatusFields { + /// Whether a reply line is a status reply. The cheap pre-filter for + /// liveness probes that only need "a daemon answered `status`". + pub fn is_status_line(reply: &str) -> bool { + reply.starts_with("status=") + } + + /// Render the wire line, byte-identical to the daemon's `status` reply. + pub fn to_wire(&self) -> String { + format!( + "status={} channel={} participants={} position={} rtl={} visible={} \ + anchor={} scale={} opacity={} offset=({},{}) monitor={} auth={} \ + show-on-fullscreen={} dim-on-hover={} hover-opacity={}", + self.status_word, + self.channel, + self.participants, + self.position, + on_off(self.rtl), + on_off(self.visible), + self.anchor, + self.scale, + self.opacity, + self.offset_x, + self.offset_y, + self.monitor.as_deref().unwrap_or("active"), + self.auth, + on_off(self.show_on_fullscreen), + on_off(self.dim_on_hover), + self.hover_opacity, + ) + } + + /// Parse a `status` reply line. Returns `None` for non-status lines. + /// Lenient bug-for-bug: an unreadable number yields 0 and a missing + /// field (older daemon) yields its default. + pub fn parse_wire(line: &str) -> Option { + if !Self::is_status_line(line) { + return None; + } + // Channel names may contain spaces, so the value is sliced between + // its markers instead of read as a token. + let channel = line + .find("channel=") + .and_then(|start| { + line[start..] + .find(" participants=") + .map(|end| line[start + "channel=".len()..start + end].to_string()) + }) + .unwrap_or_default(); + let (offset_x, offset_y) = parse_offset(token_after(line, " offset=")); + Some(Self { + status_word: word(line, "status="), + channel, + participants: number(token_after(line, " participants=")), + position: word(line, " position="), + rtl: token_after(line, " rtl=") == Some("on"), + visible: token_after(line, " visible=") == Some("on"), + anchor: word(line, " anchor="), + scale: number(token_after(line, " scale=")), + opacity: number(token_after(line, " opacity=")), + offset_x, + offset_y, + monitor: match token_after(line, " monitor=") { + Some("active") | None => None, + Some(name) => Some(name.to_string()), + }, + auth: word(line, " auth="), + show_on_fullscreen: token_after(line, " show-on-fullscreen=") == Some("on"), + dim_on_hover: token_after(line, " dim-on-hover=") == Some("on"), + hover_opacity: number(token_after(line, " hover-opacity=")), + }) + } +} + +fn on_off(value: bool) -> &'static str { + if value { "on" } else { "off" } +} + +/// The whitespace-delimited text that follows `marker` in the line, without +/// the marker. Field markers carry their leading space so they can only +/// match at a field boundary; `status=` is the exception, anchored at +/// position 0 by [`StatusFields::is_status_line`]. +fn token_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> { + let start = line.find(marker)? + marker.len(); + let rest = &line[start..]; + Some(&rest[..rest.find(' ').unwrap_or(rest.len())]) +} + +fn word(line: &str, marker: &str) -> String { + token_after(line, marker).unwrap_or_default().to_string() +} + +/// Lenient number reading, bug-for-bug: anything unparseable is 0. +fn number(value: Option<&str>) -> T { + value.and_then(|v| v.parse().ok()).unwrap_or_default() +} + +/// The `offset=(x,y)` tuple form; malformed or missing yields `(0, 0)`. +fn parse_offset(value: Option<&str>) -> (i32, i32) { + let inner = value + .unwrap_or_default() + .trim_start_matches('(') + .trim_end_matches(')'); + let (x, y) = inner.split_once(',').unwrap_or_default(); + (number(Some(x)), number(Some(y))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_preserves_every_field() { + let fields = StatusFields { + status_word: "connected".into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "bottom-right".into(), + rtl: true, + visible: true, + anchor: "top".into(), + scale: 120, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: Some("eDP-1".into()), + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: true, + hover_opacity: 40, + }; + let parsed = StatusFields::parse_wire(&fields.to_wire()).expect("roundtrip parses"); + assert_eq!(parsed.status_word, "connected"); + assert_eq!(parsed.channel, "ngobrol 3"); + assert_eq!(parsed.participants, 2); + assert_eq!(parsed.position, "bottom-right"); + assert!(parsed.rtl); + assert!(parsed.visible); + assert_eq!(parsed.anchor, "top"); + assert_eq!(parsed.scale, 120); + assert_eq!(parsed.opacity, 90); + assert_eq!(parsed.offset_x, -12); + assert_eq!(parsed.offset_y, 34); + assert_eq!(parsed.monitor, Some("eDP-1".into())); + assert_eq!(parsed.auth, "own-app"); + assert!(parsed.show_on_fullscreen); + assert!(parsed.dim_on_hover); + assert_eq!(parsed.hover_opacity, 40); + } + + #[test] + fn to_wire_matches_the_pinned_wire_line() { + let fields = StatusFields { + status_word: "connected".into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "top-left".into(), + rtl: true, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + }; + assert_eq!( + fields.to_wire(), + "status=connected channel=ngobrol 3 participants=2 position=top-left \ + rtl=on visible=off anchor=auto scale=100 opacity=90 offset=(-12,34) \ + monitor=active auth=own-app show-on-fullscreen=on dim-on-hover=off \ + hover-opacity=40" + ); + } + + #[test] + fn parse_wire_rejects_non_status_replies() { + assert_eq!(StatusFields::parse_wire("error: daemon unreachable"), None); + assert_eq!(StatusFields::parse_wire(""), None); + assert_eq!(StatusFields::parse_wire("connecting…"), None); + } + + #[test] + fn parse_wire_reads_a_lenient_participant_count_as_zero() { + // New pin, not a preserved behavior: the old tray echoed the raw + // token ("abc") into the summary. The daemon only ever writes + // numeric counts; lenient normalization to 0 is the ticket's spec. + let fields = + StatusFields::parse_wire("status=connected channel=a participants=abc").unwrap(); + assert_eq!(fields.participants, 0); + } + + #[test] + fn parse_wire_reads_a_malformed_offset_as_zero() { + // Untested-branch pin: the (0, 0) fallback for a malformed tuple. + let fields = StatusFields::parse_wire("status=connected channel=a offset=garbage").unwrap(); + assert_eq!(fields.offset_x, 0); + assert_eq!(fields.offset_y, 0); + } + + #[test] + fn parse_wire_defaults_missing_trailing_fields() { + // The oldest line shape: everything before ADR-002 appended its + // three fields at the end. + let fields = StatusFields::parse_wire( + "status=connected channel=a participants=2 position=top-left rtl=off \ + visible=on anchor=auto scale=100 opacity=90 offset=(0,0) \ + monitor=active auth=own-app", + ) + .unwrap(); + assert!(fields.visible); + assert_eq!(fields.monitor, None); + assert!(!fields.show_on_fullscreen); + assert!(!fields.dim_on_hover); + assert_eq!(fields.hover_opacity, 0); + } + + #[test] + fn parse_wire_ignores_unknown_trailing_fields() { + // ADR-002's append-at-end convention made structural: a future + // daemon's extra field must not corrupt the fields before it. + let line = "status=connected channel=ngobrol 3 participants=2 position=top-left \ + rtl=on visible=off anchor=auto scale=100 opacity=90 offset=(-12,34) \ + monitor=active auth=own-app show-on-fullscreen=on dim-on-hover=off \ + hover-opacity=40 future-knob=7"; + let fields = StatusFields::parse_wire(line).unwrap(); + assert_eq!( + fields, + StatusFields { + status_word: "connected".into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "top-left".into(), + rtl: true, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + } + ); + } + + #[test] + fn parse_wire_reads_the_status_word_as_the_first_token() { + // `exchanging token` is the only multi-word status; both current + // readers take the first whitespace-delimited token. + let fields = + StatusFields::parse_wire("status=exchanging token channel=a participants=1").unwrap(); + assert_eq!(fields.status_word, "exchanging"); + assert_eq!(fields.channel, "a"); + assert_eq!(fields.participants, 1); + } +} diff --git a/docs/adr/002-overlay-layer-for-fullscreen.md b/docs/adr/002-overlay-layer-for-fullscreen.md index b7d002a..e22d610 100644 --- a/docs/adr/002-overlay-layer-for-fullscreen.md +++ b/docs/adr/002-overlay-layer-for-fullscreen.md @@ -37,5 +37,5 @@ Conflates "whether" with "how much" and makes the GUI slider ambiguous. Two knob ## Consequences - Toggling `show-on-fullscreen` costs a daemon restart (control socket downtime ~100 ms, roster re-hydrated from cache). Same cost/UX as `monitor` changes. - Hover polling is 20 Hz only while enabled and displayable; otherwise no cursor IPC traffic. View re-renders only on hover edge transitions (diff-gated). -- `status` reply appends `show-on-fullscreen`, `dim-on-hover`, `hover-opacity` at the end (whitespace-token whitespace — existing tray parsers that split on ` ` and `=` remain compatible). +- `status` reply appends `show-on-fullscreen`, `dim-on-hover`, `hover-opacity` at the end. Amended 2026-09-03: the convention is now structural — `hyprlay_core::status::StatusFields` builds and parses the whole line, so appending a field is a one-place edit and readers survive it by construction (the hand-rolled token parsers this bullet originally justified are gone). - `CONTEXT.md` gains `Overlay layer`, `Dim on hover`, `Hover opacity` vocabulary; the `Daemon-side commands` invariant now lists `set show-on-fullscreen`. diff --git a/docs/dev/code-layout.md b/docs/dev/code-layout.md index 0fc18d8..9810adf 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -86,6 +86,7 @@ and a single reason to change. | Module | Public interface | Encapsulated responsibility | |---|---|---| | `domain.rs` | `Command::from_str` + `apply_config -> CommandResult`, `Key::ALL` | The whole CLI vocabulary, wire-protocol replies, typed status/colors — pure, no framework types; single source of truth for commands | +| `status.rs` | `StatusFields` + `to_wire`/`parse_wire`/`is_status_line` | The `status` reply wire contract in both directions — build and parse live together so field order and spelling change in one place | | `config.rs` | `load/save/clamp`, `Bounds` | TOML persistence and the single source of truth for every numeric bound (the former `toolkit` `Bounds` dissolved here) | | `color.rs` | `Rgb`, `Hsv`, conversions | Framework-free HSV/RGB color math (the former toolkit color primitives) | | `credentials.rs` | `AppCredentials`, auth.json load/save | Discord own-app credential storage; no network IO, never travels the ctl socket | diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 6215e1e..a8dc736 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -28,6 +28,7 @@ use hyprlay_core::domain::Group; use hyprlay_core::domain::Key; use hyprlay_core::domain::MonitorTarget; use hyprlay_core::domain::Value; +use hyprlay_core::status::StatusFields; use overlay::state::Overlay; /// Daemon entry point, called by the thin `src/bin/hyprlayd.rs` main. Runs @@ -192,26 +193,26 @@ pub(crate) fn resolve_command(state: &mut Overlay, cmd: Command) -> CommandOutco Command::Status => { let cfg = state.config(); let corner = hyprlay_core::domain::corner_of(cfg.horizontal, cfg.vertical); + let fields = StatusFields { + status_word: state.status().to_string(), + channel: state.channel_name().unwrap_or("-").to_string(), + participants: state.displayed().len(), + position: hyprlay_core::domain::corner_word(corner).to_string(), + rtl: cfg.rtl, + visible: cfg.visible, + anchor: cfg.anchor.to_string(), + scale: cfg.scale, + opacity: cfg.opacity, + offset_x: cfg.offset_x, + offset_y: cfg.offset_y, + monitor: cfg.monitor.clone(), + auth: state.auth_label().to_string(), + show_on_fullscreen: cfg.show_on_fullscreen, + dim_on_hover: cfg.dim_on_hover, + hover_opacity: cfg.hover_opacity, + }; return CommandOutcome { - reply: format!( - "status={} channel={} participants={} position={} rtl={} visible={} anchor={} scale={} opacity={} offset=({},{}) monitor={monitor} auth={auth} show-on-fullscreen={show_on} dim-on-hover={dim_on} hover-opacity={hover}", - state.status(), - state.channel_name().unwrap_or("-"), - state.displayed().len(), - hyprlay_core::domain::corner_word(corner), - if cfg.rtl { "on" } else { "off" }, - if cfg.visible { "on" } else { "off" }, - cfg.anchor, - cfg.scale, - cfg.opacity, - cfg.offset_x, - cfg.offset_y, - monitor = cfg.monitor.as_deref().unwrap_or("active"), - auth = state.auth_label(), - show_on = if cfg.show_on_fullscreen { "on" } else { "off" }, - dim_on = if cfg.dim_on_hover { "on" } else { "off" }, - hover = cfg.hover_opacity, - ), + reply: fields.to_wire(), effects: Vec::new(), lifecycle: None, }; diff --git a/src/gui/daemon.rs b/src/gui/daemon.rs index c39ac1e..d5561f2 100644 --- a/src/gui/daemon.rs +++ b/src/gui/daemon.rs @@ -15,6 +15,7 @@ //! daemon state and the boot auto-start watcher. use hyprlay_core::daemon_control::Toggle; +use hyprlay_core::status::StatusFields; /// Chip text before the first probe has answered. pub(super) const CONNECTING_TEXT: &str = "connecting…"; @@ -37,7 +38,7 @@ impl DaemonState { /// failure texts prove nothing does, and everything else is an ordinary /// reply that must not disturb the chip. pub(super) fn advance(self, reply: &str) -> Self { - if reply.starts_with("status=") { + if StatusFields::is_status_line(reply) { Self::Up(reply.to_string()) } else if is_probe_failure(reply) { Self::Unreachable @@ -110,7 +111,7 @@ impl AutoStart { pub(super) fn observe(&mut self, state: &mut DaemonState, reply: &str) -> Option { if self.0 == Phase::Running { // Hold the connecting line until the launch settles. - if reply.starts_with("status=") { + if StatusFields::is_status_line(reply) { *state = state.clone().advance(reply); self.0 = Phase::Done; } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8aaf988..a964df7 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -52,6 +52,7 @@ use hyprlay_core::domain::Key; use hyprlay_core::domain::Value; use hyprlay_core::domain::corner_of; use hyprlay_core::singleton::AcquireError; +use hyprlay_core::status::StatusFields; use iced::Alignment; use iced::Element; use iced::Length; @@ -375,7 +376,7 @@ fn update(gui: &mut Gui, message: Message) -> Task { } } else if reply == "saved" { gui.dirty = false; - } else if !reply.is_empty() && !reply.starts_with("status=") { + } else if !reply.is_empty() && !StatusFields::is_status_line(&reply) { // status= replies are consumed by the state chip above; // everything else is ordinary status-bar traffic. gui.last_reply = reply; @@ -1078,24 +1079,16 @@ fn daemon_toggle(gui: &Gui) -> Element<'_, Message> { } /// "status=connected channel=ngobrol 3 participants=2 …" → -/// "connected · ngobrol 3". Channel names may contain spaces, so slice on -/// the known field markers instead of words. +/// "connected · ngobrol 3". Parsing goes through the shared +/// [`StatusFields`]; channel names may contain spaces, which its +/// marker-slice handles. fn brief_status(full: &str) -> String { - if !full.starts_with("status=") { - return full.to_string(); - } - let conn = full - .strip_prefix("status=") - .and_then(|rest| rest.split(' ').next()) - .unwrap_or("unknown"); - let channel = full.find("channel=").and_then(|start| { - full[start..] - .find(" participants=") - .map(|end| &full[start + "channel=".len()..start + end]) - }); - match channel { - Some(c) => format!("{conn} · {c}"), - None => conn.to_string(), + match StatusFields::parse_wire(full) { + Some(fields) if !fields.channel.is_empty() => { + format!("{} · {}", fields.status_word, fields.channel) + } + Some(fields) => fields.status_word, + None => full.to_string(), } } @@ -1216,6 +1209,13 @@ mod tests { fn brief_status_without_channel_falls_back_to_connection_word() { assert_eq!(brief_status("status=disconnected"), "disconnected"); assert_eq!(brief_status("connecting…"), "connecting…"); + // Empty channel value (malformed Discord payload only): the old + // code printed "connected · " with a trailing separator; the new + // code drops it. Pinned here so the tightening stays deliberate. + assert_eq!( + brief_status("status=connected channel= participants=0"), + "connected" + ); } #[test] diff --git a/src/tray/menu.rs b/src/tray/menu.rs index dd28e6f..31bbb35 100644 --- a/src/tray/menu.rs +++ b/src/tray/menu.rs @@ -5,6 +5,8 @@ //! [`crate::platform::tray`] map [`build_menu`] onto real menu items, all //! driven through the shared [`Tray`](crate::tray::Tray) port. +use hyprlay_core::status::StatusFields; + /// A snapshot of daemon state the tray renders. Doubles as the diff-gate /// key: two identical snapshots must not trigger a `handle.update`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,6 +29,44 @@ impl TrayState { summary: "daemon: down".to_string(), } } + + /// Snapshot from a parsed status reply. Field semantics used: + /// - `status` word — `connected` (and anything non-empty besides `off` / + /// `disconnected`) means up; `off` / `disconnected` means down. + /// - `visible` — `on` / `off`. + /// - `channel` / `participants` — feed the compact summary. + fn from_fields(fields: &StatusFields) -> Self { + let up = !fields.status_word.is_empty() + && fields.status_word != "off" + && fields.status_word != "disconnected"; + let summary = if up { + // Connected: mirror the GUI's compact summary, adding the + // participant count. Exact copy decided here (spec: "exact copy + // finalised in code"); follows the Decisions worked example + // `connected · #general · 3`. + if fields.channel.is_empty() { + fields.status_word.clone() + } else { + format!( + "{} · {} · {}", + fields.status_word, fields.channel, fields.participants + ) + } + } else { + // Down-but-replied (e.g. `status=disconnected`): the word is the + // whole story; a missing word falls back to the down default. + if fields.status_word.is_empty() { + "daemon: down".to_string() + } else { + fields.status_word.clone() + } + }; + TrayState { + up, + visible: fields.visible, + summary, + } + } } /// Which user-facing action a menu item triggers. Sent over the action @@ -92,88 +132,15 @@ pub fn build_menu(state: &TrayState) -> Vec { ] } -/// Parse a `status=` reply (format pinned at `daemon/mod.rs:273-279`) into a -/// [`TrayState`]. -/// -/// Simple single-token fields (`status`, `participants`, `visible`) are read -/// by whitespace-delimited `key=value` tokens. The `channel` value is sliced -/// between its markers (`channel=` … ` participants=`), exactly like the -/// GUI's [`brief_status`](crate::gui): channel names may contain spaces, so a -/// naive whitespace split would corrupt them. -/// -/// Field semantics used: -/// - `status` word — `connected` (and anything non-empty besides `off` / -/// `disconnected`) means up; `off` / `disconnected` means down. -/// - `visible` — `on` / `off`. -/// - `channel` / `participants` — feed the compact summary. +/// Parse a `status=` reply into a [`TrayState`]. The wire format itself is +/// owned by [`StatusFields`] — the single source of truth shared with the +/// daemon writer and the GUI reader — so channel names with spaces and +/// lenient numbers are handled there; this only maps the parsed fields onto +/// what the tray renders. pub fn parse_status(reply: &str) -> TrayState { - if !reply.starts_with("status=") { - // Not a status reply (e.g. a connect error, or an ordinary reply): - // treat as "no live daemon". - return TrayState::down(); - } - - // Connection-status word: `status=` up to the next space. - let status_word = reply[std::cmp::min(reply.len(), "status=".len())..] - .split(' ') - .next() - .unwrap_or(""); - let up = !status_word.is_empty() && status_word != "off" && status_word != "disconnected"; - - // Channel: between `channel=` and the next ` participants=` marker, so - // spaces inside the name survive. - let channel = reply - .find("channel=") - .and_then(|start| { - reply[start..] - .find(" participants=") - .map(|end| reply[start + "channel=".len()..start + end].to_string()) - }) - .unwrap_or_default(); - - // Participant count: `participants=` up to the next space. - let participants = reply - .find("participants=") - .map(|start| { - reply[start + "participants=".len()..] - .split(' ') - .next() - .unwrap_or("") - .to_string() - }) - .unwrap_or_default(); - - // Visibility flag. - let visible = reply - .find("visible=") - .map(|start| reply[start..].split(' ').next() == Some("visible=on")) - .unwrap_or(false); - - let summary = if up { - // Connected: mirror the GUI's compact summary, adding the participant - // count. Exact copy decided here (spec: "exact copy finalised in - // code"); follows the Decisions worked example `connected · #general · 3`. - if channel.is_empty() { - status_word.to_string() - } else if participants.is_empty() { - format!("{status_word} · {channel}") - } else { - format!("{status_word} · {channel} · {participants}") - } - } else { - // Down-but-replied (e.g. `status=disconnected`): the word is the - // whole story; a missing word falls back to the down default. - if status_word.is_empty() { - "daemon: down".to_string() - } else { - status_word.to_string() - } - }; - - TrayState { - up, - visible, - summary, + match StatusFields::parse_wire(reply) { + Some(fields) => TrayState::from_fields(&fields), + None => TrayState::down(), } } From 8c4904f30545a05ed5759d937b30f9f2da4e4cdc Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 4 Sep 2026 03:24:49 +0700 Subject: [PATCH 02/12] refactor: type the writer-side status word The status reply's connection word was a bare String on both sides of the wire contract: the daemon filled it from ConnectionStatus::to_string() and readers took whatever token came off the line. The write side is now sealed behind a StatusWord newtype whose only public constructor is From, so no bare string can reach the wire and the word set cannot drift from the enum's Display spelling. The read side keeps the reader lenient bug-for-bug: parse_wire() still fills the word from the raw token, unknown words included (a newer daemon may already speak words this build has never seen, ADR-002 forward compat), and Display renders the stored word unchanged - which is what keeps to_wire() byte-identical. The tray's up/down comparison and the GUI's brief summary read through as_str(). The wire line is byte-identical: the untouched full-line pin and the roundtrip pin pass, and two new pins lock the two halves of the contract (unknown word survives verbatim; the write side accepts only the sealed vocabulary). The type change is visible through the shared StatusFields field, so the tray/GUI readers adapt mechanically (no behavior change) - the same core-plus-fronts span as the C1 wire-contract commit, hence no scope. --- crates/hyprlay-core/src/status.rs | 108 ++++++++++++++++++++++++++++-- src/daemon/mod.rs | 2 +- src/gui/mod.rs | 2 +- src/tray/menu.rs | 11 ++- 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/crates/hyprlay-core/src/status.rs b/crates/hyprlay-core/src/status.rs index 62ede29..c06bd07 100644 --- a/crates/hyprlay-core/src/status.rs +++ b/crates/hyprlay-core/src/status.rs @@ -13,11 +13,18 @@ //! `channel` value is sliced between its markers because channel names may //! contain spaces. +use std::fmt; + +use crate::domain::ConnectionStatus; + /// One parsed `status=` reply, one field per wire key. #[derive(Debug, PartialEq, Eq)] pub struct StatusFields { /// Connection-status word, e.g. `connected`, `disconnected`, `off`. - pub status_word: String, + /// Writers build it from the sealed [`ConnectionStatus`] vocabulary; + /// readers get the raw wire token, so a word this build does not know + /// still parses (forward-compat) and reaches the UI verbatim. + pub status_word: StatusWord, /// Voice-channel name; `-` when not connected. May contain spaces. pub channel: String, /// Number of roster rows the overlay displays. @@ -102,7 +109,7 @@ impl StatusFields { .unwrap_or_default(); let (offset_x, offset_y) = parse_offset(token_after(line, " offset=")); Some(Self { - status_word: word(line, "status="), + status_word: StatusWord(word(line, "status=")), channel, participants: number(token_after(line, " participants=")), position: word(line, " position="), @@ -125,6 +132,42 @@ impl StatusFields { } } +/// The connection-status word of a `status` reply. Opaque on purpose: the +/// wire contract has a sealed write side and a lenient read side. +/// +/// - **Write** (daemon): the only way in is [`From`], so a +/// bare string can never reach the wire and the word set cannot drift from +/// the enum's `Display` spelling. +/// - **Read** (tray/GUI): [`StatusFields::parse_wire`] fills the word from +/// the raw line, unknown words included — a newer daemon may already speak +/// words this build has never seen (ADR-002 forward-compat). +/// +/// [`Display`] renders the stored word unchanged in both directions, which +/// is what keeps `to_wire()` byte-identical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusWord(String); + +impl From for StatusWord { + fn from(status: ConnectionStatus) -> Self { + Self(status.to_string()) + } +} + +impl fmt::Display for StatusWord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl StatusWord { + /// The raw wire token. Readers compare or show it verbatim; nothing + /// maps it back onto [`ConnectionStatus`], because the vocabulary is + /// open on the wire (a future daemon's word must survive the round trip). + pub fn as_str(&self) -> &str { + &self.0 + } +} + fn on_off(value: bool) -> &'static str { if value { "on" } else { "off" } } @@ -165,7 +208,7 @@ mod tests { #[test] fn roundtrip_preserves_every_field() { let fields = StatusFields { - status_word: "connected".into(), + status_word: ConnectionStatus::Connected.into(), channel: "ngobrol 3".into(), participants: 2, position: "bottom-right".into(), @@ -183,7 +226,7 @@ mod tests { hover_opacity: 40, }; let parsed = StatusFields::parse_wire(&fields.to_wire()).expect("roundtrip parses"); - assert_eq!(parsed.status_word, "connected"); + assert_eq!(parsed.status_word.as_str(), "connected"); assert_eq!(parsed.channel, "ngobrol 3"); assert_eq!(parsed.participants, 2); assert_eq!(parsed.position, "bottom-right"); @@ -204,7 +247,7 @@ mod tests { #[test] fn to_wire_matches_the_pinned_wire_line() { let fields = StatusFields { - status_word: "connected".into(), + status_word: ConnectionStatus::Connected.into(), channel: "ngobrol 3".into(), participants: 2, position: "top-left".into(), @@ -284,7 +327,7 @@ mod tests { assert_eq!( fields, StatusFields { - status_word: "connected".into(), + status_word: ConnectionStatus::Connected.into(), channel: "ngobrol 3".into(), participants: 2, position: "top-left".into(), @@ -310,8 +353,59 @@ mod tests { // readers take the first whitespace-delimited token. let fields = StatusFields::parse_wire("status=exchanging token channel=a participants=1").unwrap(); - assert_eq!(fields.status_word, "exchanging"); + assert_eq!(fields.status_word.as_str(), "exchanging"); assert_eq!(fields.channel, "a"); assert_eq!(fields.participants, 1); } + + #[test] + fn parse_wire_keeps_an_unknown_status_word_verbatim() { + // The reader-side half of the StatusWord contract: a future daemon + // may speak a word this build has never seen (ADR-002 forward-compat). + // The word must survive parsing and to_wire() byte-for-byte. + let fields = StatusFields::parse_wire("status=relinking channel=a participants=1").unwrap(); + assert_eq!(fields.status_word.as_str(), "relinking"); + assert_eq!( + fields.to_wire(), + "status=relinking channel=a participants=1 position= rtl=off visible=off \ + anchor= scale=0 opacity=0 offset=(0,0) monitor=active auth= \ + show-on-fullscreen=off dim-on-hover=off hover-opacity=0" + ); + } + + #[test] + fn status_word_accepts_only_the_sealed_vocabulary_on_the_write_side() { + // The writer-side half: the daemon's word set is the enum, spelled + // exactly as its Display does (wire contract). + let cases = [ + (ConnectionStatus::Connecting, "connecting"), + (ConnectionStatus::Authorize, "authorize"), + (ConnectionStatus::Authenticating, "authenticating"), + (ConnectionStatus::ExchangingToken, "exchanging token"), + (ConnectionStatus::Connected, "connected"), + (ConnectionStatus::Disconnected, "disconnected"), + ]; + for (status, word) in cases { + let wire = StatusFields { + status_word: status.into(), + channel: "-".into(), + participants: 0, + position: "top-left".into(), + rtl: false, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 100, + offset_x: 0, + offset_y: 0, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + } + .to_wire(); + assert!(wire.starts_with(&format!("status={word} ")), "{wire}"); + } + } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index a8dc736..dc0e9d8 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -194,7 +194,7 @@ pub(crate) fn resolve_command(state: &mut Overlay, cmd: Command) -> CommandOutco let cfg = state.config(); let corner = hyprlay_core::domain::corner_of(cfg.horizontal, cfg.vertical); let fields = StatusFields { - status_word: state.status().to_string(), + status_word: state.status().into(), channel: state.channel_name().unwrap_or("-").to_string(), participants: state.displayed().len(), position: hyprlay_core::domain::corner_word(corner).to_string(), diff --git a/src/gui/mod.rs b/src/gui/mod.rs index a964df7..ef9c677 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1087,7 +1087,7 @@ fn brief_status(full: &str) -> String { Some(fields) if !fields.channel.is_empty() => { format!("{} · {}", fields.status_word, fields.channel) } - Some(fields) => fields.status_word, + Some(fields) => fields.status_word.to_string(), None => full.to_string(), } } diff --git a/src/tray/menu.rs b/src/tray/menu.rs index 31bbb35..403ae80 100644 --- a/src/tray/menu.rs +++ b/src/tray/menu.rs @@ -36,16 +36,15 @@ impl TrayState { /// - `visible` — `on` / `off`. /// - `channel` / `participants` — feed the compact summary. fn from_fields(fields: &StatusFields) -> Self { - let up = !fields.status_word.is_empty() - && fields.status_word != "off" - && fields.status_word != "disconnected"; + let status = fields.status_word.as_str(); + let up = !status.is_empty() && status != "off" && status != "disconnected"; let summary = if up { // Connected: mirror the GUI's compact summary, adding the // participant count. Exact copy decided here (spec: "exact copy // finalised in code"); follows the Decisions worked example // `connected · #general · 3`. if fields.channel.is_empty() { - fields.status_word.clone() + fields.status_word.to_string() } else { format!( "{} · {} · {}", @@ -55,10 +54,10 @@ impl TrayState { } else { // Down-but-replied (e.g. `status=disconnected`): the word is the // whole story; a missing word falls back to the down default. - if fields.status_word.is_empty() { + if status.is_empty() { "daemon: down".to_string() } else { - fields.status_word.clone() + fields.status_word.to_string() } }; TrayState { From 71aa874e33f85ff7b6852911a2bfc44a72c653a9 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 4 Sep 2026 04:05:54 +0700 Subject: [PATCH 03/12] refactor: replace stringly service errors with ServiceError hyprlay-core now owns a typed ServiceError for the DaemonControl / ServiceManager port contract. Every variant transcribes its old format! string byte-for-byte, so the observable wording on the GUI/tray status lines and the CLI's stderr is unchanged; per-variant Display pins in daemon_control lock that wording. - crates/hyprlay-core/src/daemon_control.rs: ServiceError enum (20 variants, thiserror 2), port signatures (perform, systemctl, spawn_daemon, quit_via_socket, install/uninstall with typed Err), execute_toggle renders through Display, and the Display/source characterization pins. - src/platform/service/{systemd,launchd,windows}.rs: adapters construct ServiceError at every failure site; launchd's "already bootstrapped" tolerance still string-matches on the rendered Display. - src/platform/service/mod.rs: install_service/uninstall_service return ServiceError for all three cfg-gated variants. - tests/install.rs Spy and the GUI/tray FakeControl doubles adapt mechanically (failures become ServiceError constructors); assertions stay byte-identical. thiserror 2.0.20 was already in Cargo.lock via iced, so the new hyprlay-core dependency adds no new download. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/hyprlay-core/Cargo.toml | 1 + crates/hyprlay-core/src/daemon_control.rs | 366 +++++++++++++++++++++- src/gui/daemon.rs | 16 +- src/platform/service/launchd.rs | 133 +++++--- src/platform/service/mod.rs | 22 +- src/platform/service/systemd.rs | 125 +++++--- src/platform/service/windows.rs | 69 ++-- src/tray/daemon.rs | 15 +- tests/install.rs | 25 +- 11 files changed, 612 insertions(+), 162 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cdbd461..f9d9afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1682,6 +1682,7 @@ dependencies = [ "fd-lock", "serde", "serde_json", + "thiserror 2.0.20", "toml 1.1.5+spec-1.1.0", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 1724210..1713fed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ dirs = "6" fd-lock = "4.0" interprocess = { version = "2", features = ["tokio"] } tracing = "0.1" +thiserror = "2.0" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" clap = { version = "4.5", default-features = false, features = [ diff --git a/crates/hyprlay-core/Cargo.toml b/crates/hyprlay-core/Cargo.toml index 3b1d7e6..ea189ed 100644 --- a/crates/hyprlay-core/Cargo.toml +++ b/crates/hyprlay-core/Cargo.toml @@ -16,4 +16,5 @@ serde_json = { workspace = true } toml = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } +thiserror = { workspace = true } fd-lock = { workspace = true } diff --git a/crates/hyprlay-core/src/daemon_control.rs b/crates/hyprlay-core/src/daemon_control.rs index b42061a..5c16410 100644 --- a/crates/hyprlay-core/src/daemon_control.rs +++ b/crates/hyprlay-core/src/daemon_control.rs @@ -19,6 +19,161 @@ use std::path::Path; +use crate::bins::DAEMON_BIN; + +/// Why a service-control operation failed. Part of the [`DaemonControl`] / +/// [`ServiceManager`] port contract: the backends in the host package +/// construct it, fronts render it through `Display`. +/// +/// The `Display` strings are the observable wording — toggle failures land +/// in the GUI/tray status line and install failures in the CLI's stderr — +/// so each variant transcribes its old `format!` string byte-for-byte, and +/// the per-variant pins below lock that transcription. Failure *identity* +/// (which operation, which path, which command) is typed; only payloads +/// that were already strings (subcommand words, command stderr) stay +/// strings. +/// +/// Deliberately **not** exhaustive-by-attribute at the variant level: the +/// adapters must construct every variant from outside this crate. The +/// enum-level `#[non_exhaustive]` only forces wildcard matches on future +/// consumers, leaving construction open. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ServiceError { + /// `systemctl --user ` could not be spawned at all: + /// `error: could not run systemctl: `. + #[error("error: could not run systemctl: {source}")] + SystemctlNotRun { + #[source] + source: std::io::Error, + }, + /// `systemctl --user hyprlay` exited non-zero; the detail + /// is systemctl's trimmed stderr, or `exit ` when it printed + /// nothing: `error: systemctl failed: `. + #[error("error: systemctl {subcommand} failed: {detail}")] + SystemctlFailed { subcommand: String, detail: String }, + /// A service command could not be spawned: `: could not run + /// : `. + #[error("{command}: could not run {program}: {source}")] + CommandNotRun { + command: String, + program: &'static str, + #[source] + source: std::io::Error, + }, + /// A service command exited non-zero; the detail is its trimmed stderr, + /// or `exited with ` when it printed nothing: `: + /// `. + #[error("{command}: {detail}")] + CommandFailed { command: String, detail: String }, + /// A `systemctl --user ` step of an install failed after the + /// files were written: `systemctl --user failed: `. + #[error("systemctl --user {step} failed: {source}")] + SystemctlStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// A `launchctl ` step of the start flow failed: + /// `error: launchctl failed: `. + #[error("error: launchctl {step} failed: {source}")] + LaunchctlStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// A `launchctl ` step of an install failed: + /// `launchctl failed: `. + #[error("launchctl {step} failed: {source}")] + LaunchctlInstallStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// `std::env::current_exe()` failed: `error: could not locate the + /// running hyprlay binary: `. + #[error("error: could not locate the running hyprlay binary: {source}")] + LocateExe { + #[source] + source: std::io::Error, + }, + /// The running binary's path has no parent directory: `error: could not + /// find the directory of `. + #[error("error: could not find the directory of {exe}")] + NoExeParent { exe: std::path::PathBuf }, + /// The sibling daemon binary is missing from the install directory: + /// `error: hyprlayd not found next to the running hyprlay binary + /// (expected )\nthe hyprlay binaries must be installed together`. + #[error( + "error: {} not found next to the running hyprlay binary (expected {path})\nthe hyprlay binaries must be installed together", + DAEMON_BIN + )] + DaemonMissing { path: std::path::PathBuf }, + /// Spawning the detached sibling daemon failed: `error: could not start + /// hyprlayd: `. + #[error("error: could not start {}: {source}", DAEMON_BIN)] + SpawnDaemon { + #[source] + source: std::io::Error, + }, + /// The ctl-socket quit got no answer: `error: daemon unreachable`. + #[error("error: daemon unreachable")] + DaemonUnreachable, + /// A subcommand this backend does not implement (only reachable on + /// macOS/Windows, whose backends drive a subset of the port): + /// `error: unsupported subcommand: `. + #[error("error: unsupported {backend} subcommand: {subcommand}")] + UnsupportedSubcommand { + backend: &'static str, + subcommand: String, + }, + /// A platform directory could not be resolved (the LaunchAgents dir on + /// macOS, the Startup folder on Windows): `error: could not resolve the + /// `. + #[error("error: could not resolve the {what}")] + ResolveDir { what: &'static str }, + /// The LaunchAgent plist path is not valid UTF-8, so it cannot ride in + /// a `launchctl` argument vector: `error: plist path is not UTF-8`. + #[error("error: plist path is not UTF-8")] + NonUtf8PlistPath, + /// A required sibling binary is missing from the install directory, so + /// the install aborts before writing anything: `error: missing binaries + /// for install: \nthe hyprlay binaries must be installed + /// together`. + #[error( + "error: missing binaries for install: {names}\nthe hyprlay binaries must be installed together" + )] + MissingInstallBins { names: String }, + /// Creating a service-config file's parent directory failed: + /// `could not create : `. + #[error("could not create {path}: {source}")] + CreateDirFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// Writing a service-config file failed: `could not write : `. + #[error("could not write {path}: {source}")] + WriteFileFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// Removing a service-config file failed: `could not remove : + /// `. + #[error("could not remove {path}: {source}")] + RemoveFileFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// The default install/uninstall of an OS that cannot host the hyprlay + /// service: `install is not supported on this platform` / + /// `uninstall is not supported on this platform`. + #[error("{operation} is not supported on this platform")] + UnsupportedOperation { operation: &'static str }, +} + /// Which direction a toggle press drives the daemon. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Toggle { @@ -48,7 +203,7 @@ pub enum StopPolicy { /// stays testable without touching systemctl or the daemon. pub trait DaemonControl: Send + Sync { fn unit_installed(&self) -> bool; - fn perform(&self, action: Action) -> Result<(), String>; + fn perform(&self, action: Action) -> Result<(), ServiceError>; } /// The service-management boundary behind a [`DaemonControl`]: the systemctl @@ -66,11 +221,11 @@ pub trait ServiceManager: Send + Sync { /// Whether the user service unit for the daemon is installed. fn unit_installed(&self) -> bool; /// Run `systemctl --user `. - fn systemctl(&self, subcommand: &str) -> Result<(), String>; + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError>; /// Detached spawn of the sibling daemon binary. - fn spawn_daemon(&self) -> Result<(), String>; + fn spawn_daemon(&self) -> Result<(), ServiceError>; /// Quit the running daemon over the control socket. - fn quit_via_socket(&self) -> Result<(), String>; + fn quit_via_socket(&self) -> Result<(), ServiceError>; /// Install the autostart service config next to `exe_dir` and register /// it; `start` controls whether it begins running immediately. Returns /// one human-readable line per step. @@ -80,15 +235,19 @@ pub trait ServiceManager: Send + Sync { config_base: &Path, data_base: &Path, start: bool, - ) -> Result, String> { + ) -> Result, ServiceError> { let _ = (exe_dir, config_base, data_base, start); - Err("install is not supported on this platform".to_string()) + Err(ServiceError::UnsupportedOperation { + operation: "install", + }) } /// Uninstall the autostart service config and deregister it. Returns one /// human-readable line per step. - fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, String> { + fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, ServiceError> { let _ = (config_base, data_base); - Err("uninstall is not supported on this platform".to_string()) + Err(ServiceError::UnsupportedOperation { + operation: "uninstall", + }) } } @@ -124,12 +283,15 @@ pub fn execute_toggle( stop: StopPolicy, ) -> Option { let action = plan_action(toggle, control.unit_installed(), stop); - control.perform(action).err() + control.perform(action).err().map(|e| e.to_string()) } #[cfg(test)] mod tests { + use std::error::Error; + use super::Action; + use super::ServiceError; use super::StopPolicy; use super::Toggle; use super::plan_action; @@ -181,4 +343,190 @@ mod tests { ); } } + + // ------------------------------------------------------------------- + // ServiceError characterization pins. Each row locks one variant's + // Display to the exact string the pre-typed `format!` sites produced; + // rewording any of them is a breaking change to the surfaces that print + // service errors (GUI/tray status lines, the CLI's stderr). + // ------------------------------------------------------------------- + + fn pinned_io() -> std::io::Error { + std::io::Error::other("disk on strike") + } + + fn pinned_error() -> ServiceError { + ServiceError::CommandFailed { + command: "daemon-reload".into(), + detail: "unit not found".into(), + } + } + + #[test] + fn service_error_display_reproduces_the_pinned_wording() { + use super::ServiceError as E; + let cases: [(E, &str); 21] = [ + ( + E::SystemctlNotRun { + source: pinned_io(), + }, + "error: could not run systemctl: disk on strike", + ), + ( + E::SystemctlFailed { + subcommand: "stop".into(), + detail: "unit not loaded".into(), + }, + "error: systemctl stop failed: unit not loaded", + ), + ( + E::CommandNotRun { + command: "daemon-reload".into(), + program: "systemctl", + source: pinned_io(), + }, + "daemon-reload: could not run systemctl: disk on strike", + ), + ( + E::CommandFailed { + command: "bootout gui/1000/hyprlay.user".into(), + detail: "exited with 3".into(), + }, + "bootout gui/1000/hyprlay.user: exited with 3", + ), + ( + E::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(pinned_error()), + }, + "systemctl --user daemon-reload failed: daemon-reload: unit not found", + ), + ( + E::LaunchctlStepFailed { + step: "bootstrap", + source: Box::new(pinned_error()), + }, + "error: launchctl bootstrap failed: daemon-reload: unit not found", + ), + ( + E::LaunchctlInstallStepFailed { + step: "enable", + source: Box::new(pinned_error()), + }, + "launchctl enable failed: daemon-reload: unit not found", + ), + ( + E::LocateExe { + source: pinned_io(), + }, + "error: could not locate the running hyprlay binary: disk on strike", + ), + ( + E::NoExeParent { + exe: "/proc/self/exe".into(), + }, + "error: could not find the directory of /proc/self/exe", + ), + ( + E::DaemonMissing { + path: "/opt/hyprlay/hyprlayd".into(), + }, + "error: hyprlayd not found next to the running hyprlay binary \ + (expected /opt/hyprlay/hyprlayd)\nthe hyprlay binaries must be installed together", + ), + ( + E::SpawnDaemon { + source: pinned_io(), + }, + "error: could not start hyprlayd: disk on strike", + ), + (E::DaemonUnreachable, "error: daemon unreachable"), + ( + E::UnsupportedSubcommand { + backend: "Windows startup", + subcommand: "reload".into(), + }, + "error: unsupported Windows startup subcommand: reload", + ), + ( + E::ResolveDir { + what: "LaunchAgents dir", + }, + "error: could not resolve the LaunchAgents dir", + ), + (E::NonUtf8PlistPath, "error: plist path is not UTF-8"), + ( + E::MissingInstallBins { + names: "hyprlayd".into(), + }, + "error: missing binaries for install: hyprlayd\nthe hyprlay binaries must be installed together", + ), + ( + E::CreateDirFailed { + path: "/home/u/.config/hyprlay/systemd/user".into(), + source: pinned_io(), + }, + "could not create /home/u/.config/hyprlay/systemd/user: disk on strike", + ), + ( + E::WriteFileFailed { + path: "/home/u/.config/hyprlay/systemd/user/hyprlay.service".into(), + source: pinned_io(), + }, + "could not write /home/u/.config/hyprlay/systemd/user/hyprlay.service: disk on strike", + ), + ( + E::RemoveFileFailed { + path: "/home/u/.local/share/applications/hyprlay.desktop".into(), + source: pinned_io(), + }, + "could not remove /home/u/.local/share/applications/hyprlay.desktop: disk on strike", + ), + ( + E::UnsupportedOperation { + operation: "install", + }, + "install is not supported on this platform", + ), + ( + E::UnsupportedOperation { + operation: "uninstall", + }, + "uninstall is not supported on this platform", + ), + ]; + for (error, expected) in cases { + assert_eq!(error.to_string(), expected, "{error:?}"); + } + } + + #[test] + fn io_failures_keep_the_io_error_as_their_source() { + let inner = pinned_io(); + let error = ServiceError::WriteFileFailed { + path: "/tmp/hyprlay.service".into(), + source: inner, + }; + let source = error + .source() + .expect("the io::Error rides along as the source"); + assert!(source.downcast_ref::().is_some()); + } + + #[test] + fn nested_step_failures_chain_through_their_source() { + let inner = pinned_error(); + let error = ServiceError::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(inner), + }; + let source = error + .source() + .expect("the wrapped service failure is the source"); + assert_eq!( + source.to_string(), + "daemon-reload: unit not found", + "the cause chain ends at the raw command failure" + ); + } } diff --git a/src/gui/daemon.rs b/src/gui/daemon.rs index d5561f2..5ef2749 100644 --- a/src/gui/daemon.rs +++ b/src/gui/daemon.rs @@ -143,6 +143,7 @@ impl AutoStart { mod tests { use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; + use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::StopPolicy; use hyprlay_core::daemon_control::execute_toggle; @@ -306,7 +307,10 @@ mod tests { fn a_failed_action_surfaces_its_error_text() { let control = FakeControl { installed: false, - fail_with: Some("error: systemctl stop failed: unit not loaded".into()), + fail_with: Some(Box::new(|| ServiceError::SystemctlFailed { + subcommand: "stop".into(), + detail: "unit not loaded".into(), + })), ..FakeControl::default() }; // Stop without a unit resolves to the socket path; its failure must @@ -415,11 +419,13 @@ mod tests { } /// Spy at the process/socket boundary: records what ran so tests verify - /// state, not call mechanics. + /// state, not call mechanics. `fail_with` is a factory because + /// `ServiceError` is not `Clone`: every performed action fails the same + /// way, exactly as the old `String` double did. #[derive(Default)] struct FakeControl { installed: bool, - fail_with: Option, + fail_with: Option ServiceError + Send + Sync>>, performed: std::sync::Mutex>, } @@ -434,10 +440,10 @@ mod tests { self.installed } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { self.performed.lock().unwrap().push(action); match &self.fail_with { - Some(text) => Err(text.clone()), + Some(failure) => Err(failure()), None => Ok(()), } } diff --git a/src/platform/service/launchd.rs b/src/platform/service/launchd.rs index a2aae67..77a799f 100644 --- a/src/platform/service/launchd.rs +++ b/src/platform/service/launchd.rs @@ -17,6 +17,7 @@ use std::process::Command; use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; @@ -69,21 +70,30 @@ impl Launchd { ) } - fn run_launchctl(&self, args: &[&str]) -> Result<(), String> { + fn run_launchctl(&self, args: &[&str]) -> Result<(), ServiceError> { let joined = args.join(" "); let output = Command::new("launchctl") .args(args) .output() - .map_err(|e| format!("{joined}: could not run launchctl: {e}"))?; + .map_err(|source| ServiceError::CommandNotRun { + command: joined.clone(), + program: "launchctl", + source, + })?; if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = stderr.trim(); - if detail.is_empty() { - Err(format!("{joined}: exited with {}", output.status)) + Ok(()) } else { - Err(format!("{joined}: {detail}")) + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + let detail = if detail.is_empty() { + format!("exited with {}", output.status) + } else { + detail.to_string() + }; + Err(ServiceError::CommandFailed { + command: joined, + detail, + }) } } } @@ -93,65 +103,67 @@ impl ServiceManager for Launchd { Self::plist_path().is_some_and(|path| path.exists()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { let uid = Self::uid(); match subcommand { "start" => { - let plist = Self::plist_path() - .ok_or_else(|| "error: could not resolve the LaunchAgents dir".to_string())?; - let plist = plist - .to_str() - .ok_or_else(|| "error: plist path is not UTF-8".to_string())?; + let plist = Self::plist_path().ok_or(ServiceError::ResolveDir { + what: "LaunchAgents dir", + })?; + let plist = plist.to_str().ok_or(ServiceError::NonUtf8PlistPath)?; // bootstrap loads + starts the agent; "already bootstrapped" // means it is running, which is the desired outcome. let domain = format!("gui/{uid}"); let args = vec!["bootstrap", domain.as_str(), plist]; match self.run_launchctl(&args) { Ok(()) => {} - Err(e) if e.contains("already bootstrapped") => return Ok(()), - Err(e) => return Err(format!("error: launchctl bootstrap failed: {e}")), + Err(e) if e.to_string().contains("already bootstrapped") => return Ok(()), + Err(source) => { + return Err(ServiceError::LaunchctlStepFailed { + step: "bootstrap", + source: Box::new(source), + }); + } } let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["enable", label.as_str()]) - .map_err(|e| format!("error: launchctl enable failed: {e}")) + .map_err(|source| ServiceError::LaunchctlStepFailed { + step: "enable", + source: Box::new(source), + }) } "stop" => { let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["bootout", label.as_str()]) } - other => Err(format!("error: unsupported launchd subcommand: {other}")), + other => Err(ServiceError::UnsupportedSubcommand { + backend: "launchd", + subcommand: other.to_string(), + }), } } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = dir.join(DAEMON_BIN); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // process_group(0) + null stdio + no wait: the daemon must outlive // this binary and never hold its terminal. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -160,33 +172,42 @@ impl ServiceManager for Launchd { _config_base: &Path, _data_base: &Path, _start: bool, - ) -> Result, String> { - let path = Self::plist_path() - .ok_or_else(|| "error: could not resolve the LaunchAgents dir".to_string())?; + ) -> Result, ServiceError> { + let path = Self::plist_path().ok_or(ServiceError::ResolveDir { + what: "LaunchAgents dir", + })?; write_plist(&path, &Self::plist(exe_dir))?; let uid = Self::uid(); let mut report = vec![format!("wrote {}", path.display())]; - let plist = path - .to_str() - .ok_or_else(|| "error: plist path is not UTF-8".to_string())?; + let plist = path.to_str().ok_or(ServiceError::NonUtf8PlistPath)?; let domain = format!("gui/{uid}"); let args = vec!["bootstrap", domain.as_str(), plist]; - if let Err(e) = self.run_launchctl(&args) { + if let Err(source) = self.run_launchctl(&args) { // A fresh install is never already bootstrapped, so a bootstrap // failure here is real. - return Err(format!("launchctl bootstrap failed: {e}")); + return Err(ServiceError::LaunchctlInstallStepFailed { + step: "bootstrap", + source: Box::new(source), + }); } report.push(format!("launchctl bootstrap gui/{uid} {plist}: ok")); let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["enable", label.as_str()]) - .map_err(|e| format!("launchctl enable failed: {e}"))?; + .map_err(|source| ServiceError::LaunchctlInstallStepFailed { + step: "enable", + source: Box::new(source), + })?; report.push(format!( "launchctl enable gui/{uid}/{LAUNCH_AGENT_LABEL}: ok" )); Ok(report) } - fn uninstall(&self, _config_base: &Path, _data_base: &Path) -> Result, String> { + fn uninstall( + &self, + _config_base: &Path, + _data_base: &Path, + ) -> Result, ServiceError> { let uid = Self::uid(); let mut report = Vec::new(); let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); @@ -213,7 +234,7 @@ impl DaemonControl for SystemControl { Launchd.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => Launchd.systemctl("start"), Action::SystemctlStop => Launchd.systemctl("stop"), @@ -223,21 +244,31 @@ impl DaemonControl for SystemControl { } } -fn write_plist(path: &Path, contents: &str) -> Result<(), String> { +fn write_plist(path: &Path, contents: &str) -> Result<(), ServiceError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) } -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { +fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { match fs::remove_file(path) { Ok(()) => report.push(format!("removed {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { report.push(format!("already absent {}", path.display())) } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), + Err(source) => { + return Err(ServiceError::RemoveFileFailed { + path: path.to_path_buf(), + source, + }); + } } Ok(()) } diff --git a/src/platform/service/mod.rs b/src/platform/service/mod.rs index 741ec9a..969fe27 100644 --- a/src/platform/service/mod.rs +++ b/src/platform/service/mod.rs @@ -14,6 +14,7 @@ pub mod windows; use std::path::Path; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; #[cfg(target_os = "macos")] pub use launchd::Launchd; @@ -35,13 +36,16 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { Systemd.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "linux")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { Systemd.uninstall(config_base, data_base) } @@ -52,13 +56,16 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { Launchd.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "macos")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { Launchd.uninstall(config_base, data_base) } @@ -69,12 +76,15 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { WindowsService.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "windows")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { WindowsService.uninstall(config_base, data_base) } diff --git a/src/platform/service/systemd.rs b/src/platform/service/systemd.rs index ecc0a61..5db537f 100644 --- a/src/platform/service/systemd.rs +++ b/src/platform/service/systemd.rs @@ -26,6 +26,7 @@ use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; use hyprlay_core::daemon_control::SERVICE_UNIT; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; @@ -46,11 +47,11 @@ impl ServiceManager for Systemd { .is_ok_and(|status| status.success()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { let output = Command::new("systemctl") .args(["--user", subcommand, SERVICE_UNIT]) .output() - .map_err(|e| format!("error: could not run systemctl: {e}"))?; + .map_err(|source| ServiceError::SystemctlNotRun { source })?; if output.status.success() { return Ok(()); } @@ -60,25 +61,20 @@ impl ServiceManager for Systemd { } else { detail }; - Err(format!("error: systemctl {subcommand} failed: {detail}")) + Err(ServiceError::SystemctlFailed { + subcommand: subcommand.to_string(), + detail, + }) } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = dir.join(DAEMON_BIN); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // Own process group + null stdio + no wait: the daemon must outlive @@ -86,13 +82,13 @@ impl ServiceManager for Systemd { // guarded daemon-side by the socket probe. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -101,11 +97,11 @@ impl ServiceManager for Systemd { config_base: &Path, data_base: &Path, start: bool, - ) -> Result, String> { + ) -> Result, ServiceError> { install(exe_dir, config_base, data_base, start, &RealSystemctl) } - fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, String> { + fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, ServiceError> { uninstall(config_base, data_base, &RealSystemctl) } } @@ -120,7 +116,7 @@ impl DaemonControl for SystemControl { Systemd.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => Systemd.systemctl("start"), Action::SystemctlStop => Systemd.systemctl("stop"), @@ -134,7 +130,7 @@ impl DaemonControl for SystemControl { /// Owned wrapper around an external binary, so tests substitute a recording /// double instead of invoking real units. pub trait Systemctl { - fn run(&self, args: &[&str]) -> Result<(), String>; + fn run(&self, args: &[&str]) -> Result<(), ServiceError>; } /// Production runner: captures exit status and stderr so flow errors carry @@ -142,23 +138,31 @@ pub trait Systemctl { struct RealSystemctl; impl Systemctl for RealSystemctl { - fn run(&self, args: &[&str]) -> Result<(), String> { + fn run(&self, args: &[&str]) -> Result<(), ServiceError> { let joined = args.join(" "); let output = Command::new("systemctl") .arg("--user") .args(args) .output() - .map_err(|e| format!("{joined}: could not run systemctl: {e}"))?; + .map_err(|source| ServiceError::CommandNotRun { + command: joined.clone(), + program: "systemctl", + source, + })?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); let detail = stderr.trim(); - if detail.is_empty() { - Err(format!("{joined}: exited with {}", output.status)) + let detail = if detail.is_empty() { + format!("exited with {}", output.status) } else { - Err(format!("{joined}: {detail}")) - } + detail.to_string() + }; + Err(ServiceError::CommandFailed { + command: joined, + detail, + }) } } } @@ -270,13 +274,12 @@ pub fn install( data_base: &Path, start: bool, systemctl: &dyn Systemctl, -) -> Result, String> { +) -> Result, ServiceError> { let missing = missing_bins(exe_dir); if !missing.is_empty() { - return Err(format!( - "error: missing binaries for install: {}\nthe hyprlay binaries must be installed together", - missing.join(", ") - )); + return Err(ServiceError::MissingInstallBins { + names: missing.join(", "), + }); } let unit = unit_path(config_base); @@ -296,17 +299,26 @@ pub fn install( systemctl .run(&["daemon-reload"]) - .map_err(|e| format!("systemctl --user daemon-reload failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(source), + })?; report.push("systemctl --user daemon-reload: ok".to_string()); if start { systemctl .run(&["enable", "--now", "hyprlay"]) - .map_err(|e| format!("systemctl --user enable --now hyprlay failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "enable --now hyprlay", + source: Box::new(source), + })?; report.push("systemctl --user enable --now hyprlay: ok".to_string()); systemctl .run(&["enable", "--now", "hyprlay-tray"]) - .map_err(|e| format!("systemctl --user enable --now hyprlay-tray failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "enable --now hyprlay-tray", + source: Box::new(source), + })?; report.push("systemctl --user enable --now hyprlay-tray: ok".to_string()); } else { report.push("skipped systemctl --user enable --now (--no-start)".to_string()); @@ -320,7 +332,7 @@ pub fn uninstall( config_base: &Path, data_base: &Path, systemctl: &dyn Systemctl, -) -> Result, String> { +) -> Result, ServiceError> { let mut report = Vec::new(); match systemctl.run(&["disable", "--now", "hyprlay"]) { Ok(()) => report.push("systemctl --user disable --now hyprlay: ok".to_string()), @@ -341,27 +353,37 @@ pub fn uninstall( Ok(report) } -fn write_file(path: &Path, contents: &str) -> Result<(), String> { +fn write_file(path: &Path, contents: &str) -> Result<(), ServiceError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) } -fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), String> { +fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), ServiceError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) } /// Install the app icon into the user's hicolor theme so the `Icon=hyprlay` /// desktop entry resolves to a real image. The SVG scales to any size; the /// PNG raster sizes cover launchers that do not read SVG. Bytes are bundled /// into the binary (same pattern as the tray icons). Returns the paths written. -fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), String> { +fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { let svg = icon_svg_path(data_base); write_bytes(&svg, include_bytes!("../../../assets/hyprlay.svg"))?; report.push(format!("wrote {}", svg.display())); @@ -382,7 +404,7 @@ fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), String } /// Remove the installed app icon files. `scalable` SVG plus each raster size. -fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), String> { +fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { remove_reported(&icon_svg_path(data_base), report)?; for size in ICON_SIZES { remove_reported(&icon_png_path(data_base, *size), report)?; @@ -390,13 +412,18 @@ fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), Stri Ok(()) } -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { +fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { match fs::remove_file(path) { Ok(()) => report.push(format!("removed {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { report.push(format!("already absent {}", path.display())) } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), + Err(source) => { + return Err(ServiceError::RemoveFileFailed { + path: path.to_path_buf(), + source, + }); + } } Ok(()) } diff --git a/src/platform/service/windows.rs b/src/platform/service/windows.rs index bd43f2c..1ea7f92 100644 --- a/src/platform/service/windows.rs +++ b/src/platform/service/windows.rs @@ -22,6 +22,7 @@ use std::process::Command; use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; @@ -63,47 +64,40 @@ impl ServiceManager for WindowsService { Self::script_path().is_some_and(|path| path.exists()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { match subcommand { // No service manager: starting "via the service" is a spawn, and // stopping it is a socket quit (the only reliable teardown). "start" => self.spawn_daemon(), "stop" => self.quit_via_socket(), - other => Err(format!( - "error: unsupported Windows startup subcommand: {other}" - )), + other => Err(ServiceError::UnsupportedSubcommand { + backend: "Windows startup", + subcommand: other.to_string(), + }), } } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = Self::daemon_exe(dir); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + null stdio + no wait: // the daemon must outlive this binary and never hold a console. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -112,14 +106,19 @@ impl ServiceManager for WindowsService { _config_base: &Path, _data_base: &Path, _start: bool, - ) -> Result, String> { - let path = Self::script_path() - .ok_or_else(|| "error: could not resolve the Startup folder".to_string())?; + ) -> Result, ServiceError> { + let path = Self::script_path().ok_or(ServiceError::ResolveDir { + what: "Startup folder", + })?; write_script(&path, &Self::launcher(exe_dir))?; Ok(vec![format!("wrote {}", path.display())]) } - fn uninstall(&self, _config_base: &Path, _data_base: &Path) -> Result, String> { + fn uninstall( + &self, + _config_base: &Path, + _data_base: &Path, + ) -> Result, ServiceError> { let mut report = Vec::new(); if let Some(path) = Self::script_path() { remove_reported(&path, &mut report)?; @@ -136,7 +135,7 @@ impl DaemonControl for SystemControl { WindowsService.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => WindowsService.systemctl("start"), Action::SystemctlStop => WindowsService.systemctl("stop"), @@ -146,21 +145,31 @@ impl DaemonControl for SystemControl { } } -fn write_script(path: &Path, contents: &str) -> Result<(), String> { +fn write_script(path: &Path, contents: &str) -> Result<(), ServiceError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) } -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { +fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { match fs::remove_file(path) { Ok(()) => report.push(format!("removed {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { report.push(format!("already absent {}", path.display())) } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), + Err(source) => { + return Err(ServiceError::RemoveFileFailed { + path: path.to_path_buf(), + source, + }); + } } Ok(()) } diff --git a/src/tray/daemon.rs b/src/tray/daemon.rs index f0f0502..e39b274 100644 --- a/src/tray/daemon.rs +++ b/src/tray/daemon.rs @@ -172,6 +172,7 @@ fn spawn_gui(exe: &std::path::Path) -> Result<(), String> { mod tests { use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; + use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::StopPolicy; use hyprlay_core::daemon_control::Toggle; use hyprlay_core::daemon_control::execute_toggle; @@ -209,11 +210,13 @@ mod tests { } /// Spy at the process/socket boundary: records what ran so tests verify - /// state, not call mechanics. + /// state, not call mechanics. `fail_with` is a factory because + /// `ServiceError` is not `Clone`: every performed action fails the same + /// way, exactly as the old `String` double did. #[derive(Default)] struct FakeControl { installed: bool, - fail_with: Option, + fail_with: Option ServiceError + Send + Sync>>, performed: std::sync::Mutex>, } @@ -228,10 +231,10 @@ mod tests { self.installed } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { self.performed.lock().unwrap().push(action); match &self.fail_with { - Some(text) => Err(text.clone()), + Some(failure) => Err(failure()), None => Ok(()), } } @@ -252,7 +255,9 @@ mod tests { fn a_failed_action_surfaces_its_error_text() { let control = FakeControl { installed: false, - fail_with: Some("error: could not start hyprlayd: ENOENT".into()), + fail_with: Some(Box::new(|| ServiceError::SpawnDaemon { + source: std::io::Error::other("ENOENT"), + })), ..FakeControl::default() }; let outcome = execute_toggle(&control, Toggle::Stop, StopPolicy::ViaSocket); diff --git a/tests/install.rs b/tests/install.rs index 4d9d822..d527e1e 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -15,6 +15,7 @@ use common::unique_temp_dir; use hyprlay::platform::service::systemd::Systemctl; use hyprlay::platform::service::systemd::install; use hyprlay::platform::service::systemd::uninstall; +use hyprlay_core::daemon_control::ServiceError; const DAEMON_RELOAD: &str = "systemctl --user daemon-reload"; const ENABLE_NOW: &str = "systemctl --user enable --now hyprlay"; @@ -52,13 +53,14 @@ impl Spy { } impl Systemctl for Spy { - fn run(&self, args: &[&str]) -> Result<(), String> { + fn run(&self, args: &[&str]) -> Result<(), ServiceError> { let line = format!("systemctl --user {}", args.join(" ")); self.calls.borrow_mut().push(line.clone()); match &self.fail_when { - Some(failing) if *failing == line => { - Err("Failed to connect: unit not found".to_string()) - } + Some(failing) if *failing == line => Err(ServiceError::CommandFailed { + command: args.join(" "), + detail: "Failed to connect: unit not found".to_string(), + }), _ => Ok(()), } } @@ -393,7 +395,10 @@ fn failed_daemon_reload_reports_the_step_and_never_enables() { ) .unwrap_err(); - assert!(err.contains("daemon-reload"), "error names the step: {err}"); + assert!( + err.to_string().contains("daemon-reload"), + "error names the step: {err}" + ); assert_eq!(spy.lines(), vec![DAEMON_RELOAD.to_string()]); } @@ -412,7 +417,10 @@ fn failed_enable_now_reports_the_step_after_writing_both_files() { ) .unwrap_err(); - assert!(err.contains("enable"), "error names the step: {err}"); + assert!( + err.to_string().contains("enable"), + "error names the step: {err}" + ); assert_eq!( spy.lines(), vec![DAEMON_RELOAD.to_string(), ENABLE_NOW.to_string()] @@ -505,7 +513,10 @@ fn install_aborts_before_any_write_when_bins_are_missing() { .unwrap_err(); // The error names the single required bin. - assert!(err.contains(DAEMON_BIN), "error names hyprlayd: {err}"); + assert!( + err.to_string().contains(DAEMON_BIN), + "error names hyprlayd: {err}" + ); // Nothing was written before the check failed. assert!(!world.unit().exists(), "daemon unit must not be written"); assert!(!world.tray_unit().exists(), "tray unit must not be written"); From 05ac0f28ed1536833f974418911a0055b22d8996 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 4 Sep 2026 08:41:06 +0700 Subject: [PATCH 04/12] refactor(daemon): derive the roster cache from the Participant type CachedUser duplicated Participant's seven persisted fields and was kept in sync by two hand-copied From impls; roster_signature enumerated the fields a third time, so a field added to one list silently broke write dedup. Participant now carries the serde derives itself (speaking is #[serde(skip)]: live-only state, filled with false on load, exactly the old conversion semantics), Roster.users holds it directly, and roster_signature is built from serde output so the signature and the roster.json format can no longer disagree. Cache bytes are unchanged; pins cover the legacy file format both ways and the per-field signature sensitivity. --- src/daemon/adapters/cache.rs | 215 +++++++++++++++++++++++---------- src/daemon/adapters/discord.rs | 9 +- src/daemon/overlay/state.rs | 4 +- 3 files changed, 160 insertions(+), 68 deletions(-) diff --git a/src/daemon/adapters/cache.rs b/src/daemon/adapters/cache.rs index 4307ac4..fc69f56 100644 --- a/src/daemon/adapters/cache.rs +++ b/src/daemon/adapters/cache.rs @@ -22,70 +22,26 @@ pub fn cache_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -pub struct CachedUser { - pub id: String, - pub name: String, - pub avatar_hash: Option, - pub self_mute: bool, - pub self_deaf: bool, - pub server_mute: bool, - pub server_deaf: bool, -} - -impl From<&Participant> for CachedUser { - fn from(p: &Participant) -> Self { - Self { - id: p.id.clone(), - name: p.name.clone(), - avatar_hash: p.avatar_hash.clone(), - self_mute: p.self_mute, - self_deaf: p.self_deaf, - server_mute: p.server_mute, - server_deaf: p.server_deaf, - } - } -} - -impl From for Participant { - fn from(u: CachedUser) -> Self { - // Speaking state is live-only: it would be stale on load. - Participant { - id: u.id, - name: u.name, - avatar_hash: u.avatar_hash, - speaking: false, - self_mute: u.self_mute, - self_deaf: u.self_deaf, - server_mute: u.server_mute, - server_deaf: u.server_deaf, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Roster { pub channel: Option, pub me_id: Option, - pub users: Vec, + pub users: Vec, } -/// Cheap identity of a roster for write dedup — everything except the -/// per-event speaking flag. -fn roster_signature(users: &[CachedUser]) -> String { +/// Cheap identity of a roster for write dedup — the serde output of every +/// participant, so the signature covers exactly the fields the cache file +/// persists and can never drift away from the file format. The live-only +/// speaking flag is skipped by serde and therefore never reaches either. +/// A silent `unwrap_or_default()` fallback is deliberately not used: it +/// would turn every failing entry into the same empty string, collapsing +/// distinct users into equal signatures and silently skipping writes. +fn roster_signature(users: &[Participant]) -> String { users .iter() .map(|u| { - format!( - "{}|{}|{:?}|{}{}{}{}", - u.id, - u.name, - u.avatar_hash, - u.self_mute as u8, - u.self_deaf as u8, - u.server_mute as u8, - u.server_deaf as u8 - ) + serde_json::to_string(u) + .expect("roster entries are plain strings and bools; they always serialize") }) .collect::>() .join(";") @@ -98,8 +54,7 @@ fn last_signature() -> &'static Mutex { /// Persist the roster if it changed since the last write. pub fn save_roster(channel: Option<&str>, me_id: Option<&str>, users: &[Participant]) { - let cached: Vec = users.iter().map(CachedUser::from).collect(); - let sig = format!("{:?}|{:?}|{}", channel, me_id, roster_signature(&cached)); + let sig = format!("{:?}|{:?}|{}", channel, me_id, roster_signature(users)); { let mut last = last_signature().lock().unwrap(); if *last == sig { @@ -110,7 +65,7 @@ pub fn save_roster(channel: Option<&str>, me_id: Option<&str>, users: &[Particip let roster = Roster { channel: channel.map(str::to_string), me_id: me_id.map(str::to_string), - users: cached, + users: users.to_vec(), }; let dir = cache_dir(); let write = || -> std::io::Result<()> { @@ -174,7 +129,7 @@ mod tests { id: id.to_string(), name: name.to_string(), avatar_hash: hash.map(str::to_string), - speaking: true, // must be dropped by the conversion + speaking: true, // must be dropped by serde self_mute: mutes.0, self_deaf: mutes.1, server_mute: false, @@ -182,11 +137,136 @@ mod tests { } } + /// The exact bytes the cache wrote before the roster type was unified: + /// the seven persisted user fields in declaration order, no `speaking` + /// key anywhere. Pinned so existing caches load with no migration. + const LEGACY_ROSTER_JSON: &str = r#"{"channel":"General","me_id":"238492734982739483","users":[{"id":"238492734982739483","name":"fazuh","avatar_hash":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","self_mute":true,"self_deaf":false,"server_mute":false,"server_deaf":false},{"id":"1079875395007417102","name":"quiet_guest","avatar_hash":null,"self_mute":false,"self_deaf":false,"server_mute":true,"server_deaf":false}]}"#; + + #[test] + fn roster_cache_from_the_previous_format_still_loads() { + let roster: Roster = serde_json::from_str(LEGACY_ROSTER_JSON).unwrap(); + assert_eq!(roster.channel.as_deref(), Some("General")); + assert_eq!(roster.me_id.as_deref(), Some("238492734982739483")); + assert_eq!(roster.users.len(), 2); + assert_eq!(roster.users[0].id, "238492734982739483"); + assert_eq!(roster.users[0].name, "fazuh"); + assert_eq!( + roster.users[0].avatar_hash.as_deref(), + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") + ); + assert!(roster.users[0].self_mute); + assert!(!roster.users[0].self_deaf); + assert!(!roster.users[0].server_mute); + assert!(!roster.users[0].server_deaf); + assert_eq!(roster.users[1].id, "1079875395007417102"); + assert_eq!(roster.users[1].name, "quiet_guest"); + assert_eq!(roster.users[1].avatar_hash, None); + assert!(roster.users[1].server_mute); + // The live-only speaking flag never survives a load: serde fills + // it with false, same as the previous cache format's load path. + assert!(!roster.users[0].speaking); + assert!(!roster.users[1].speaking); + } + #[test] - fn cached_user_roundtrip_drops_speaking_flag() { + fn roster_cache_writes_the_same_bytes_as_the_previous_format() { + let mut server_muted = + participant("1079875395007417102", "quiet_guest", None, (false, false)); + server_muted.server_mute = true; + // Both participants carry speaking=true on purpose: the flag is + // skipped by serde, so the bytes stay legacy-identical. + let roster = Roster { + channel: Some("General".to_string()), + me_id: Some("238492734982739483".to_string()), + users: vec![ + participant( + "238492734982739483", + "fazuh", + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"), + (true, false), + ), + server_muted, + ], + }; + assert_eq!(serde_json::to_string(&roster).unwrap(), LEGACY_ROSTER_JSON); + } + + #[test] + fn roster_signature_changes_when_any_persisted_field_changes() { + let base = participant( + "238492734982739483", + "fazuh", + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"), + (true, false), + ); + let sig = |u: &Participant| roster_signature(std::slice::from_ref(u)); + let base_sig = sig(&base); + + let mut changed = base.clone(); + changed.id = "1079875395007417102".to_string(); + assert_ne!(sig(&changed), base_sig, "id must reach the signature"); + + let mut changed = base.clone(); + changed.name = "renamed".to_string(); + assert_ne!(sig(&changed), base_sig, "name must reach the signature"); + + let mut changed = base.clone(); + changed.avatar_hash = None; + assert_ne!( + sig(&changed), + base_sig, + "avatar_hash must reach the signature" + ); + + let mut changed = base.clone(); + changed.self_mute = false; + assert_ne!( + sig(&changed), + base_sig, + "self_mute must reach the signature" + ); + + let mut changed = base.clone(); + changed.self_deaf = true; + assert_ne!( + sig(&changed), + base_sig, + "self_deaf must reach the signature" + ); + + let mut changed = base.clone(); + changed.server_mute = true; + assert_ne!( + sig(&changed), + base_sig, + "server_mute must reach the signature" + ); + + let mut changed = base.clone(); + changed.server_deaf = true; + assert_ne!( + sig(&changed), + base_sig, + "server_deaf must reach the signature" + ); + + // Parity with the old hand-written signature: it enumerated only + // the persisted fields, so a speaking flip must stay invisible. + let mut changed = base.clone(); + changed.speaking = !changed.speaking; + assert_eq!( + sig(&changed), + base_sig, + "speaking must not reach the signature" + ); + } + + #[test] + fn participant_serde_roundtrip_drops_speaking_flag() { let p = participant("42", "fazuh", Some("abc"), (true, false)); - let back = CachedUser::from(&p); - let restored: Participant = back.into(); + let json = serde_json::to_string(&p).unwrap(); + assert!(!json.contains("speaking")); + let restored: Participant = serde_json::from_str(&json).unwrap(); assert_eq!(restored.id, "42"); assert_eq!(restored.name, "fazuh"); assert_eq!(restored.avatar_hash.as_deref(), Some("abc")); @@ -196,15 +276,18 @@ mod tests { #[test] fn roster_signature_ignores_speaking_but_not_mutes() { - let mut a = CachedUser::from(&participant("1", "a", None, (false, false))); - let b = CachedUser::from(&participant("1", "a", None, (false, false))); + let mut a = participant("1", "a", None, (false, false)); + let b = participant("1", "a", None, (false, false)); // Same user twice → equal signatures. assert_eq!( - roster_signature(&[a.clone()]), + roster_signature(std::slice::from_ref(&a)), roster_signature(std::slice::from_ref(&b)) ); a.self_mute = true; - assert_ne!(roster_signature(&[a]), roster_signature(&[b])); + assert_ne!( + roster_signature(std::slice::from_ref(&a)), + roster_signature(std::slice::from_ref(&b)) + ); } #[test] diff --git a/src/daemon/adapters/discord.rs b/src/daemon/adapters/discord.rs index 3e3a761..5e097f7 100644 --- a/src/daemon/adapters/discord.rs +++ b/src/daemon/adapters/discord.rs @@ -18,6 +18,8 @@ use std::time::Duration; use futures_channel::mpsc::Sender; use futures_util::SinkExt; use hyprlay_core::domain::ConnectionStatus; +use serde::Deserialize; +use serde::Serialize; use serde_json::Value; use serde_json::json; use tracing::info; @@ -36,11 +38,16 @@ pub enum DiscordEvent { Participants(Vec), } -#[derive(Debug, Clone, PartialEq)] +/// One voice-channel participant. Doubles as the roster-cache row: serde +/// output defines both the `roster.json` file format and the write-dedup +/// signature, so a new field cannot be added to one without the other. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Participant { pub id: String, pub name: String, pub avatar_hash: Option, + /// Speaking state is live-only: it would be stale on load. + #[serde(skip)] pub speaking: bool, pub self_mute: bool, pub self_deaf: bool, diff --git a/src/daemon/overlay/state.rs b/src/daemon/overlay/state.rs index 23e92c8..97735ab 100644 --- a/src/daemon/overlay/state.rs +++ b/src/daemon/overlay/state.rs @@ -275,7 +275,9 @@ impl Overlay { if let Some(roster) = crate::daemon::adapters::cache::load_roster() { self.channel_name = roster.channel; self.me_id = roster.me_id; - self.users = roster.users.into_iter().map(Participant::from).collect(); + // Deserialization already restored the persisted fields; the + // live-only speaking flag came back as false. + self.users = roster.users; self.avatars.hydrate(&self.users); } } From 66ec46f9dbdd050f502038caccd703a481cbee2b Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 4 Sep 2026 09:29:57 +0700 Subject: [PATCH 05/12] refactor(platform): dedupe the service write/remove helpers into fs_util The three service backends repeated the same two fs sequences byte for byte: create-parent + write (four copies under four names: systemd's write_file/write_bytes, launchd's write_plist, windows' write_script) and the reported remove (three identical remove_reported bodies). Both now live once in service/fs_util, taking &[u8]; string call sites pass .as_bytes(). Error variants, payloads, and report strings are unchanged, so the install.rs byte and report pins pass unmodified and windows.rs stays compile-only-verified. --- src/platform/service/fs_util.rs | 37 +++++++++++++++++++ src/platform/service/launchd.rs | 35 ++---------------- src/platform/service/mod.rs | 2 ++ src/platform/service/systemd.rs | 64 ++++++--------------------------- src/platform/service/windows.rs | 35 ++---------------- 5 files changed, 56 insertions(+), 117 deletions(-) create mode 100644 src/platform/service/fs_util.rs diff --git a/src/platform/service/fs_util.rs b/src/platform/service/fs_util.rs new file mode 100644 index 0000000..721832a --- /dev/null +++ b/src/platform/service/fs_util.rs @@ -0,0 +1,37 @@ +//! File-system helpers shared by the service backends: the identical +//! create-parent + write and reported-remove sequences the systemd, +//! launchd, and Windows adapters each used to repeat. + +use std::fs; +use std::path::Path; + +use hyprlay_core::daemon_control::ServiceError; + +pub(super) fn write_file(path: &Path, contents: &[u8]) -> Result<(), ServiceError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) +} + +pub(super) fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { + match fs::remove_file(path) { + Ok(()) => report.push(format!("removed {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + report.push(format!("already absent {}", path.display())) + } + Err(source) => { + return Err(ServiceError::RemoveFileFailed { + path: path.to_path_buf(), + source, + }); + } + } + Ok(()) +} diff --git a/src/platform/service/launchd.rs b/src/platform/service/launchd.rs index 77a799f..e24001e 100644 --- a/src/platform/service/launchd.rs +++ b/src/platform/service/launchd.rs @@ -9,7 +9,6 @@ //! keeps it alive (`KeepAlive`) so a crash/laptop wake relaunches it. It is //! registered in the `gui/` domain with `launchctl bootstrap`. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,6 +21,7 @@ use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The launchd label for the user LaunchAgent (matches the systemd unit name @@ -176,7 +176,7 @@ impl ServiceManager for Launchd { let path = Self::plist_path().ok_or(ServiceError::ResolveDir { what: "LaunchAgents dir", })?; - write_plist(&path, &Self::plist(exe_dir))?; + fs_util::write_file(&path, Self::plist(exe_dir).as_bytes())?; let uid = Self::uid(); let mut report = vec![format!("wrote {}", path.display())]; let plist = path.to_str().ok_or(ServiceError::NonUtf8PlistPath)?; @@ -220,7 +220,7 @@ impl ServiceManager for Launchd { )), } if let Some(path) = Self::plist_path() { - remove_reported(&path, &mut report)?; + fs_util::remove_reported(&path, &mut report)?; } Ok(report) } @@ -243,32 +243,3 @@ impl DaemonControl for SystemControl { } } } - -fn write_plist(path: &Path, contents: &str) -> Result<(), ServiceError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { - path: path.to_path_buf(), - source, - }) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(source) => { - return Err(ServiceError::RemoveFileFailed { - path: path.to_path_buf(), - source, - }); - } - } - Ok(()) -} diff --git a/src/platform/service/mod.rs b/src/platform/service/mod.rs index 969fe27..63b9155 100644 --- a/src/platform/service/mod.rs +++ b/src/platform/service/mod.rs @@ -12,6 +12,8 @@ pub mod systemd; #[cfg(target_os = "windows")] pub mod windows; +mod fs_util; + use std::path::Path; use hyprlay_core::daemon_control::ServiceError; diff --git a/src/platform/service/systemd.rs b/src/platform/service/systemd.rs index 5db537f..4d64158 100644 --- a/src/platform/service/systemd.rs +++ b/src/platform/service/systemd.rs @@ -15,7 +15,6 @@ //! `Icon=hyprlay` and install/uninstall also manage the hicolor theme icon //! files (SVG + PNG raster sizes) so the launcher shows the brand mark. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -31,6 +30,7 @@ use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The systemd user-startup backend. @@ -285,9 +285,9 @@ pub fn install( let unit = unit_path(config_base); let tray_unit = tray_unit_path(config_base); let desktop = desktop_path(data_base); - write_file(&unit, &unit_text(exe_dir))?; - write_file(&tray_unit, &tray_unit_text(exe_dir))?; - write_file(&desktop, &desktop_text(exe_dir))?; + fs_util::write_file(&unit, unit_text(exe_dir).as_bytes())?; + fs_util::write_file(&tray_unit, tray_unit_text(exe_dir).as_bytes())?; + fs_util::write_file(&desktop, desktop_text(exe_dir).as_bytes())?; let mut report = vec![ format!("wrote {}", unit.display()), @@ -346,46 +346,20 @@ pub fn uninstall( "systemctl --user disable --now hyprlay-tray: tolerated ({e})" )), } - remove_reported(&unit_path(config_base), &mut report)?; - remove_reported(&tray_unit_path(config_base), &mut report)?; - remove_reported(&desktop_path(data_base), &mut report)?; + fs_util::remove_reported(&unit_path(config_base), &mut report)?; + fs_util::remove_reported(&tray_unit_path(config_base), &mut report)?; + fs_util::remove_reported(&desktop_path(data_base), &mut report)?; uninstall_icon(data_base, &mut report)?; Ok(report) } -fn write_file(path: &Path, contents: &str) -> Result<(), ServiceError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { - path: path.to_path_buf(), - source, - }) -} - -fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), ServiceError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { - path: path.to_path_buf(), - source, - }) -} - /// Install the app icon into the user's hicolor theme so the `Icon=hyprlay` /// desktop entry resolves to a real image. The SVG scales to any size; the /// PNG raster sizes cover launchers that do not read SVG. Bytes are bundled /// into the binary (same pattern as the tray icons). Returns the paths written. fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { let svg = icon_svg_path(data_base); - write_bytes(&svg, include_bytes!("../../../assets/hyprlay.svg"))?; + fs_util::write_file(&svg, include_bytes!("../../../assets/hyprlay.svg"))?; report.push(format!("wrote {}", svg.display())); for size in ICON_SIZES { @@ -397,7 +371,7 @@ fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), Servic 256 => include_bytes!("../../../assets/hyprlay-256.png"), _ => unreachable!(), }; - write_bytes(&png, bytes)?; + fs_util::write_file(&png, bytes)?; report.push(format!("wrote {}", png.display())); } Ok(()) @@ -405,25 +379,9 @@ fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), Servic /// Remove the installed app icon files. `scalable` SVG plus each raster size. fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { - remove_reported(&icon_svg_path(data_base), report)?; + fs_util::remove_reported(&icon_svg_path(data_base), report)?; for size in ICON_SIZES { - remove_reported(&icon_png_path(data_base, *size), report)?; - } - Ok(()) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(source) => { - return Err(ServiceError::RemoveFileFailed { - path: path.to_path_buf(), - source, - }); - } + fs_util::remove_reported(&icon_png_path(data_base, *size), report)?; } Ok(()) } diff --git a/src/platform/service/windows.rs b/src/platform/service/windows.rs index 1ea7f92..6f959bb 100644 --- a/src/platform/service/windows.rs +++ b/src/platform/service/windows.rs @@ -14,7 +14,6 @@ //! quit — the same two primitives the tray uses. Only the autostart install //! is Windows-specific. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -27,6 +26,7 @@ use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The Startup-folder launcher file name. @@ -110,7 +110,7 @@ impl ServiceManager for WindowsService { let path = Self::script_path().ok_or(ServiceError::ResolveDir { what: "Startup folder", })?; - write_script(&path, &Self::launcher(exe_dir))?; + fs_util::write_file(&path, Self::launcher(exe_dir).as_bytes())?; Ok(vec![format!("wrote {}", path.display())]) } @@ -121,7 +121,7 @@ impl ServiceManager for WindowsService { ) -> Result, ServiceError> { let mut report = Vec::new(); if let Some(path) = Self::script_path() { - remove_reported(&path, &mut report)?; + fs_util::remove_reported(&path, &mut report)?; } Ok(report) } @@ -144,32 +144,3 @@ impl DaemonControl for SystemControl { } } } - -fn write_script(path: &Path, contents: &str) -> Result<(), ServiceError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { - path: path.to_path_buf(), - source, - }) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(source) => { - return Err(ServiceError::RemoveFileFailed { - path: path.to_path_buf(), - source, - }); - } - } - Ok(()) -} From 81700094b770cf47cebbcaf528d38dbf8c55a7e6 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 4 Sep 2026 14:04:26 +0700 Subject: [PATCH 06/12] refactor(gui): split the app core into update/commands/scroll/view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod.rs was a 1449-line grab-bag holding every TEA layer at once; each concern now has exactly one home. update.rs carries the one flat update match (the app's dispatch table, deliberately not per-domain), shortcut, and the async toggle/auth effects. commands.rs owns Message→Command translation plus the shared bookkeeping (mark_dirty, num_in_bounds, revert_commands). scroll.rs owns one-page navigation: the measure operation, section jumps, scrollspy, and the two shared widget ids. view.rs owns the window composition (header, sidebar, status bar). mod.rs keeps the shell: Message, Gui, run, boot, subscribe, window settings, and the blocking send wrapper. Behavior-identical: every moved body is byte-verbatim (verified by mechanical extraction diff against the old file, modulo pub(super) visibility, rustfmt line-wrapping, and forced test-path edits). All 21 tests moved with their subjects — registry/section pins to fields.rs, command/revert pins to commands.rs, scrollspy pins to scroll.rs, brief_status pins to view.rs, navigation state-transition pins to update.rs — pins semantically identical. Visibility is minimal: Message and Gui stay private in mod.rs (descendant access is free by Rust privacy; the compiler proves it); pub(super) only on the items a sibling or the parent actually calls. picker.rs's update import now names the function inside the new update module. --- .../003-settings-single-page-navigation.md | 8 +- docs/dev/code-layout.md | 6 +- src/gui/commands.rs | 218 +++ src/gui/fields.rs | 89 ++ src/gui/mod.rs | 1174 +---------------- src/gui/picker.rs | 2 +- src/gui/scroll.rs | 261 ++++ src/gui/update.rs | 496 +++++++ src/gui/view.rs | 191 +++ 9 files changed, 1271 insertions(+), 1174 deletions(-) create mode 100644 src/gui/commands.rs create mode 100644 src/gui/scroll.rs create mode 100644 src/gui/update.rs create mode 100644 src/gui/view.rs diff --git a/docs/adr/003-settings-single-page-navigation.md b/docs/adr/003-settings-single-page-navigation.md index d02b63d..5ea2ed8 100644 --- a/docs/adr/003-settings-single-page-navigation.md +++ b/docs/adr/003-settings-single-page-navigation.md @@ -9,7 +9,7 @@ Accepted ## Context The settings GUI (`hyprlay-gui`, `src/gui/`) shows one section at a time. -The sidebar sends `Message::Section(Section)` (`src/gui/mod.rs`, `sidebar`), +The sidebar sends `Message::Section(Section)` (`src/gui/view.rs`, `sidebar`), and `section_page(gui, section)` (`src/gui/fields.rs`) renders that section alone inside its own scrollable. Moving between sections means a full page swap, so the user cannot see Position and Layout together or compare values @@ -32,7 +32,7 @@ sources for the needed mechanics: - `iced_runtime::task::widget(operation)` runs a custom operation and delivers its `finish()` output as a message. The repo already uses a runtime operation this way (`iced_runtime::widget::operation::focus` in - `src/gui/mod.rs`). + `src/gui/update.rs`). The repo uses this same widget-id mechanism for the search box (`SEARCH_ID` in `src/gui/fields.rs`). @@ -108,12 +108,12 @@ fires exactly when the offset changes, so polling is strictly more work. end. `Navigate → Measured → scroll_to` fires `on_scroll` once on landing, which re-measures and re-derives the same section. - The sidebar highlight stays suppressed while search text is set; the - existing selected condition in `sidebar` (`src/gui/mod.rs`) keeps its + existing selected condition in `sidebar` (`src/gui/view.rs`) keeps its `gui.search.trim().is_empty()` guard. - New state on `Gui`: `section_offsets` and `last_scroll_y`, plus the `CONTENT_SCROLL_ID` and per-section anchor widget ids. Two pure helpers, `active_section_for` and `offset_within_content`, carry the math and are - unit-tested in `src/gui/mod.rs`. + unit-tested in `src/gui/scroll.rs`. - Offsets measured at jump time mean stale measurements cannot send the viewport to the wrong place, at the cost of one measure operation per scroll event batch. diff --git a/docs/dev/code-layout.md b/docs/dev/code-layout.md index 9810adf..403b1c3 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -59,7 +59,11 @@ boundary. | `src/daemon/adapters/ipc.rs` | transport-agnostic `IpcStream` + `DiscordTransport` port | Discord's local IPC wire format: 8-byte LE header, handshake, PING/PONG; per-OS discovery + connect (unix socket / named pipe) behind the package-local `DiscordTransport` port (Adapter) | | `src/daemon/adapters/auth.rs` | `detect() -> Option`, `exchange(code)` | Credential resolution (env → auth.json) and the OAuth code exchange | | `src/daemon/adapters/{cache,avatar,token}.rs` | roster/avatar/token stores | On-disk persistence with tracing on real failures | -| `src/gui/mod.rs` | iced app `Gui::run()` | Window layout (header / sidebar / content / status bar), field registry, search; every change becomes a `Command` | +| `src/gui/mod.rs` | iced app shell: `Gui::run()` | `Message`, `Gui`, boot/subscribe wiring, window settings, and the blocking `send` wrapper; every change stays a `Command` — the layer modules below own the rest | +| `src/gui/update.rs` | `pub(super)` `update(gui, msg)` | The one flat update match (the app's dispatch table), the `shortcut` dispatcher, and the async daemon-toggle / auth effects | +| `src/gui/commands.rs` | `pub(super)` `command_for`, `apply_num`, `revert_commands` | Message → Command translation plus the bookkeeping the update arms share: unsaved marker, numeric bounds check, revert diff | +| `src/gui/scroll.rs` | `pub(super)` `measure_sections`, `scroll_to_section` | One-page navigation: the measure operation, section jumps, scrollspy highlight, and the shared widget ids | +| `src/gui/view.rs` | `pub(super)` `view(gui)` | Window composition: header (title, search, global actions), sidebar (section anchors), status bar (unsaved marker, daemon toggle, last reply) | | `src/gui/fields.rs` | per-key field registry | Section, label, tooltip, and control rendering for each setting | | `src/gui/daemon.rs` | `DaemonState` machine | Status chip states (connecting… / up / daemon not active) and the Start/Stop toggle plumbing (systemctl vs spawn vs `quit`) | | `src/gui/picker.rs` | color picker widget | Color selection UI | diff --git a/src/gui/commands.rs b/src/gui/commands.rs new file mode 100644 index 0000000..e8efc35 --- /dev/null +++ b/src/gui/commands.rs @@ -0,0 +1,218 @@ +//! Message → Command translation and the bookkeeping the update arms +//! share: the unsaved-changes marker, the numeric bounds check, and the +//! revert diff behind "clear changes". + +use hyprlay_core::config::Config; +use hyprlay_core::domain::Command; +use hyprlay_core::domain::Key; +use hyprlay_core::domain::Value; +use hyprlay_core::domain::corner_of; +use iced::Task; + +use super::Gui; +use super::Message; +use super::send; + +/// Commit one numeric value to the mirror and the daemon. The caller has +/// already made sure the value is inside the key's bounds. +pub(super) fn apply_num(gui: &mut Gui, key: Key, value: i64) -> Task { + gui.num_drafts.remove(&key); + let command = Command::Set(key, Value::Num(value)); + mark_dirty(gui, &command); + command.clone().apply_config(&mut gui.config); + Task::perform(send(command.to_string()), Message::Applied) +} + +/// Mirror of the daemon's persistence rule: a change is "unsaved" exactly +/// when the daemon would not have persisted it. Decided with the +/// pre-application autosave value — the same one the daemon uses — so +/// flipping auto-save itself never leaves a phantom badge. +pub(super) fn mark_dirty(gui: &mut Gui, command: &Command) { + if !hyprlay_core::domain::should_persist(command, gui.config.auto_save) { + gui.dirty = true; + } +} + +pub(super) fn num_in_bounds(key: Key, v: i64) -> bool { + key.num_bounds() + .is_some_and(|(min, max)| v >= min && v <= max) +} + +/// Commands that bring `live` back to `saved`, one per differing key. +/// Used by "clear changes"; empty when there is nothing to revert. Walking +/// the shared [`Key`] table means a newly added setting can never be +/// forgotten here — it shows up in the diff the moment it exists. +pub(super) fn revert_commands(live: &Config, saved: &Config) -> Vec { + Key::ALL + .into_iter() + .filter(|k| k.value_of(live) != k.value_of(saved)) + .map(|k| Command::Set(k, k.value_of(saved))) + .collect() +} + +/// Turn a GUI interaction into its control-socket command. The local config +/// mirror is the same [`Command::apply_config`] the daemon runs, so both +/// sides can never disagree about what a setting change means. +pub(super) fn command_for(message: Message) -> Command { + match message { + Message::Position(h, v) => Command::Set(Key::Position, Value::Corner(corner_of(h, v))), + // Rides the generic apply path like Position: mirror locally, send + // the same wire command the CLI would. + Message::Anchor(mode) => Command::Set(Key::Anchor, Value::Anchor(mode)), + Message::SetFlag(..) => unreachable!("flags are handled directly in update"), + // Handled directly in `update`; unreachable here. + Message::NumText(..) + | Message::NumDrag(..) + | Message::NumReset(_) + | Message::ColorPart(..) + | Message::ColorHex(..) + | Message::PickerToggle(..) + | Message::SvPress(..) + | Message::SvMove(..) + | Message::HuePress(..) + | Message::HueMove(..) + | Message::PickerRelease + | Message::Palette(_) + | Message::Navigate(_) + | Message::Scrolled(_) + | Message::Measured { .. } + | Message::Search(_) + | Message::KeyPressed(_) + | Message::Save + | Message::ClearChanges + | Message::ResetAll + | Message::ResetSection(_) + | Message::SwitchMonitor(_) + | Message::Monitors(_) + | Message::Applied(_) + | Message::RefreshStatus + | Message::ToggleDaemon + | Message::ToggleResult(_) + | Message::AuthClientId(_) + | Message::AuthClientSecret(_) + | Message::AuthApply => unreachable!("handled before command_for"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchor_setting_roundtrips_through_apply_and_revert() { + // The exact Command path the GUI's generic change pipeline drives. + let mut live = Config::default(); + let pin_bottom = Command::Set( + Key::Anchor, + Value::Anchor(hyprlay_core::config::AnchorMode::Bottom), + ); + pin_bottom.clone().apply_config(&mut live); + assert_eq!(live.anchor, hyprlay_core::config::AnchorMode::Bottom); + + // Reverting mirrors what "clear changes" replays: read the saved + // value back through the shared table and re-apply it. + let saved = Config::default(); + let revert = Command::Set(Key::Anchor, Key::Anchor.value_of(&saved)); + revert.apply_config(&mut live); + assert_eq!(live.anchor, saved.anchor); + } + + #[test] + fn key_sets_use_the_cli_wire_names() { + use hyprlay_core::config::OFFSETS; + assert_eq!( + Command::Set(Key::Opacity, Value::Num(42)).to_string(), + "set opacity 42" + ); + assert_eq!( + Command::Set(Key::OffsetX, Value::Num(-12)).to_string(), + "set offset-x -12" + ); + assert_eq!( + Command::Set(Key::TalkingOnly, Value::Flag(true)).to_string(), + "set talking-only on" + ); + assert!( + (OFFSETS.min as i64..=OFFSETS.max as i64).contains(&-12), + "test value must stay inside the shared bounds" + ); + } + + #[test] + fn every_numeric_key_has_sane_bounds() { + for key in Key::ALL { + if let Some((min, max)) = key.num_bounds() { + assert!(min <= max, "{} has an inverted range", key.name()); + } else { + // Non-numeric keys must not pretend to have slider bounds. + assert!( + !key.slider_bounds(&Config::default()).is_some() + || matches!( + key, + Key::OffsetX + | Key::OffsetY + | Key::Width + | Key::Scale + | Key::AvatarSize + | Key::TextSize + | Key::Spacing + | Key::MaxName + | Key::Opacity + | Key::AvatarOpacity + | Key::TextOpacity + | Key::BoxOpacity + ), + "{} renders a slider without numeric bounds", + key.name() + ); + } + } + } + + #[test] + fn revert_commands_do_nothing_when_configs_match() { + let cfg = Config::default(); + assert!(revert_commands(&cfg, &cfg).is_empty()); + } + + #[test] + fn revert_commands_cover_every_differing_key_once() { + use hyprlay_core::config::HorizontalAnchor as H; + use hyprlay_core::config::VerticalAnchor as V; + // show_own_user defaults to true, so flipping it off is a real diff. + let saved = Config { + horizontal: H::Right, + vertical: V::Top, // top-right corner + rtl: true, + offset_x: 40, + opacity: 70, + width: 500, + show_own_user: false, + monitor: Some("DP-2".into()), + speaking_color: "#00ff00".parse().unwrap(), + ..Config::default() + }; + + let cmds = revert_commands(&Config::default(), &saved); + for expected in [ + "set position top-right", + "set rtl on", + "set offset-x 40", + "set opacity 70", + "set width 500", + "set own-user off", + "set monitor DP-2", + ] { + assert!( + cmds.iter().any(|c| c.to_string() == expected), + "missing revert command {expected}" + ); + } + assert!( + cmds.iter() + .any(|c| c.to_string().starts_with("set speaking-color ")) + ); + // Exactly one command per changed key — no redundant spam. + assert_eq!(cmds.len(), 8, "unexpected extra commands: {cmds:?}"); + } +} diff --git a/src/gui/fields.rs b/src/gui/fields.rs index 6f80c0b..b0469b4 100644 --- a/src/gui/fields.rs +++ b/src/gui/fields.rs @@ -795,3 +795,92 @@ fn preset_button<'a>(cfg: &'a Config, h: H, v: V, label: &'a str) -> Element<'a, .width(Length::Fill) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_matches_label_tip_and_section_name() { + let field = Field { + section: Section::Position, + label: "offset x", + tip: "Horizontal distance in px from the anchored screen edge.", + render: f_offset_x, + }; + assert!(search_matches(&field, "offset")); + assert!(search_matches(&field, "horizontal")); + assert!(search_matches(&field, "POSITION")); + assert!(!search_matches(&field, "avatar")); + assert!(!search_matches(&field, "")); + } + + #[test] + fn sections_map_one_to_one_onto_config_groups() { + use hyprlay_core::domain::Group; + // Only config-backed sections participate; Connection has no group. + let config_backed: Vec<_> = Section::ALL + .into_iter() + .filter_map(|section| section.group().map(|group| (section, group))) + .collect(); + assert_eq!(config_backed.len(), Group::ALL.len()); + for ((section, group), expected) in config_backed.into_iter().zip(Group::ALL) { + // The reset button sends one ResetGroup per GUI section; if a + // section ever fails to map, its fields could never be reset. + assert_eq!(group, expected); + assert_eq!( + format!("{expected}"), + section.name().to_lowercase(), + "section {} diverged from group {}", + section.name(), + group + ); + } + } + + /// Exactly one section is exempt from the reset machinery, and its name + /// must stay stable because the shortcut hints and search rely on it. + #[test] + fn every_section_except_connection_maps_to_a_group() { + for section in Section::ALL { + match section.group() { + Some(_) => assert_ne!(section.name(), "Connection"), + None => assert_eq!(section.name(), "Connection"), + } + } + } + + #[test] + fn every_field_has_a_nonempty_tooltip() { + for f in FIELDS { + assert!(!f.tip.is_empty(), "field {} needs a tooltip", f.label); + assert!(!f.label.is_empty()); + } + } + + #[test] + fn every_section_has_fields() { + for s in Section::ALL { + assert!( + FIELDS.iter().any(|f| f.section == s), + "section {} has no fields", + s.name() + ); + } + } + + /// Click-through was removed; no field may render it again. + #[test] + fn click_through_is_gone_from_the_field_registry() { + assert!(!FIELDS.iter().any(|f| f.label.contains("click"))); + } + + #[test] + fn anchor_field_is_registered_in_the_position_section() { + let field = FIELDS + .iter() + .find(|f| f.label == "anchor") + .expect("anchor field registered"); + assert_eq!(field.section, Section::Position); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ef9c677..c487c1b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -21,72 +21,38 @@ //! changes is allowed (the daemon keeps runtime state, but a daemon //! restart reverts to config.toml). +mod commands; mod daemon; mod fields; mod picker; +mod scroll; mod theme; +mod update; +mod view; use std::collections::HashMap; use std::sync::Arc; use daemon::AutoStart; use daemon::DaemonState; -use fields::CONTENT_SCROLL_ID; -use fields::SEARCH_ID; use fields::Section; -use fields::search_page; -use fields::settings_page; use hyprlay_core::config::Config; use hyprlay_core::config::HorizontalAnchor as H; -use hyprlay_core::config::PALETTES; use hyprlay_core::config::VerticalAnchor as V; use hyprlay_core::config::{self}; -use hyprlay_core::credentials::AppCredentials; use hyprlay_core::ctl; use hyprlay_core::daemon_control::DaemonControl; -use hyprlay_core::daemon_control::StopPolicy; -use hyprlay_core::daemon_control::Toggle; use hyprlay_core::domain::Command; -use hyprlay_core::domain::HexColor; use hyprlay_core::domain::Key; -use hyprlay_core::domain::Value; -use hyprlay_core::domain::corner_of; use hyprlay_core::singleton::AcquireError; -use hyprlay_core::status::StatusFields; -use iced::Alignment; -use iced::Element; -use iced::Length; use iced::Point; -use iced::Rectangle; use iced::Subscription; use iced::Task; -use iced::Vector; -use iced::keyboard::key; use iced::keyboard::{self}; -use iced::widget::Id; -use iced::widget::button; -use iced::widget::column; -use iced::widget::container; -use iced::widget::row; -use iced::widget::text; -use iced::widget::text_input; -use iced_runtime::core::widget::Operation; -use iced_runtime::core::widget::operation::Outcome; -use iced_runtime::core::widget::operation::scrollable::Scrollable; use picker::ColorTarget; -use picker::apply_hue; -use picker::apply_sv; -use theme::AMBER; -use theme::BRIGHT; -use theme::HEADER_BG; -use theme::MUTED; -use theme::REPLY_GREEN; -use theme::SIDEBAR_BG; -use theme::nav_style; -use theme::panel; -use theme::plain_style; -use theme::primary_style; use theme::theme_for; +use update::update; +use view::view; use crate::platform::service::SystemControl; @@ -319,1131 +285,3 @@ async fn send(command: String) -> String { .await .unwrap_or_else(|_| "error: command task failed".into()) } - -/// Run one Start/Stop action (systemctl, sibling spawn, or socket quit) off -/// the UI thread, same blocking pattern as [`send`]. -async fn run_toggle(control: Arc, toggle: Toggle) -> Option { - tokio::task::spawn_blocking(move || { - hyprlay_core::daemon_control::execute_toggle(&*control, toggle, StopPolicy::ViaSystemctl) - }) - .await - .unwrap_or_else(|e| Some(format!("error: daemon toggle task failed: {e}"))) -} - -/// Persist own-app credentials off the UI thread, then ask the daemon to -/// restart so it re-runs detect() and picks up the new backend. The -/// returned text lands in the status bar via [`Message::Applied`]. -async fn apply_auth_credentials(creds: AppCredentials) -> String { - // Read before the move: the decision text depends on what was applied. - let cleared = creds.client_id.is_empty() && creds.client_secret.is_empty(); - let saved = tokio::task::spawn_blocking(move || hyprlay_core::credentials::save(&creds)) - .await - .unwrap_or_else(|e| Err(std::io::Error::other(e.to_string()))); - match saved { - Ok(()) => { - // The daemon's own reply only confirms delivery; the meaningful - // text for the status bar is ours. - let _ = send("restart".to_string()).await; - if cleared { - "credentials cleared, restarting daemon".to_string() - } else { - "credentials saved, restarting daemon".to_string() - } - } - Err(e) => format!("error: could not write credentials: {e}"), - } -} - -fn update(gui: &mut Gui, message: Message) -> Task { - match message { - Message::Applied(reply) => { - let reply = reply.trim().to_string(); - // Every reply is a potential probe outcome; only probe outcomes - // actually move the state (see DaemonState::advance) — and - // while the boot auto-start has the wheel, failures hold - // `connecting…` instead of reporting the daemon dead. - let launch = gui.auto_start.observe(&mut gui.daemon_state, &reply); - // `dump` replies with the live runtime config as TOML — adopt it - // so the GUI reflects unsaved daemon state. The [position] - // header marks a dump; any other text is an ordinary reply. Any - // in-flight input drafts are stale after an external reset, so - // drop them too. - if reply.contains("[position]") { - if let Ok(live) = toml::from_str::(&reply) { - gui.config = live; - gui.drafts.clear(); - gui.num_drafts.clear(); - } - } else if reply == "saved" { - gui.dirty = false; - } else if !reply.is_empty() && !StatusFields::is_status_line(&reply) { - // status= replies are consumed by the state chip above; - // everything else is ordinary status-bar traffic. - gui.last_reply = reply; - } - match launch { - Some(toggle) => { - // Opening the GUI brings the daemon up: fire-and-forget - // off the UI thread, through the same DaemonControl path - // as the Start button. This is also why closing the - // window never stops the daemon — nothing here ties its - // lifetime to GUI exit (systemctl owns the unit; the - // fallback spawn detaches into its own process group). - let control = Arc::clone(&gui.control); - Task::perform(run_toggle(control, toggle), Message::ToggleResult) - } - None => Task::none(), - } - } - Message::RefreshStatus => { - Task::perform(send(Command::Status.to_string()), Message::Applied) - } - Message::ToggleDaemon => { - let Some(toggle) = gui.daemon_state.toggle() else { - return Task::none(); - }; - let control = Arc::clone(&gui.control); - Task::perform(run_toggle(control, toggle), Message::ToggleResult) - } - Message::ToggleResult(failure) => { - // The boot bring-up attempt finished either way; stop holding - // the connecting line on its behalf. - gui.auto_start.settled(); - if let Some(text) = failure { - gui.last_reply = text; - } - // Whether it worked is only visible through a fresh probe; do - // not wait for the next 2 s tick. - Task::perform(send(Command::Status.to_string()), Message::Applied) - } - Message::Monitors(monitors) => { - gui.monitors = monitors; - Task::none() - } - Message::Save => { - gui.dirty = false; - Task::perform(send(Command::Save.to_string()), Message::Applied) - } - Message::ClearChanges => { - // Revert the daemon's runtime state to the on-disk config by - // replaying only the fields that actually differ. - let saved = config::load(); - let commands = revert_commands(&gui.config, &saved); - gui.config = saved; - gui.drafts.clear(); - gui.num_drafts.clear(); - gui.dirty = false; - Task::batch( - commands - .into_iter() - .map(|c| Task::perform(send(c.to_string()), Message::Applied)), - ) - } - Message::ResetAll => { - mark_dirty(gui, &Command::ResetAll); - Task::perform(send(Command::ResetAll.to_string()), Message::Applied).chain( - Task::perform(send(Command::Dump.to_string()), Message::Applied), - ) - } - Message::ResetSection(section) => { - // Sections without a config group have nothing to reset; the - // GUI hides their button, so this arm is a defensive no-op. - let Some(group) = section.group() else { - return Task::none(); - }; - let command = Command::ResetGroup(group); - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied).chain(Task::perform( - send(Command::Dump.to_string()), - Message::Applied, - )) - } - // `monitor` is answered by the shell before apply_config runs, so - // the mirror must be updated here or the chip highlight lags behind. - Message::SwitchMonitor(target) => { - let command = Command::Set( - Key::Monitor, - Value::Target(match &target { - None => hyprlay_core::domain::MonitorTarget::Active, - Some(name) => hyprlay_core::domain::MonitorTarget::Named(name.clone()), - }), - ); - gui.config.monitor = target; - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } - Message::Palette(index) => { - let Some(p) = PALETTES.get(index) else { - return Task::none(); - }; - let cmds = [ - Command::Set(Key::SpeakingColor, Value::Color(p.speaking)), - Command::Set(Key::TextColor, Value::Color(p.text)), - Command::Set(Key::BoxColor, Value::Color(p.box_bg)), - ]; - for cmd in &cmds { - cmd.clone().apply_config(&mut gui.config); - } - // All three palette entries are Sets: one decision covers them. - mark_dirty(gui, &cmds[0]); - Task::batch( - cmds.into_iter() - .map(|c| Task::perform(send(c.to_string()), Message::Applied)), - ) - } - Message::Navigate(section) => { - // D3: jumping while searching first returns to the one-page - // view; the measure task below runs against the layout built - // after this re-render, so its offsets are fresh. - if !gui.search.trim().is_empty() { - gui.search.clear(); - } - // Immediate highlight — don't make the sidebar wait for the - // measure round-trip. - gui.section = section; - measure_sections(Some(section)) - } - Message::Scrolled(offset_y) => { - // Continuously tracked so a search-clear can restore it (D4); - // nothing reports Scrolled while the search page is up, so the - // value freezes at its pre-search state. - gui.last_scroll_y = offset_y; - measure_sections(None) - } - Message::Measured { - offsets, - max_scroll, - jump, - } => match jump { - Some(section) => scroll_to_section(section, offsets), - None => { - // Scrollspy: at the very end of the page the last header - // can never reach the viewport top (Connection is shorter - // than the viewport), so a bottomed-out scroll maps to - // INFINITY and clamps to the last section. This branch - // only sees a scrollable page: while the content fits its - // viewport no scroll event fires, so max_scroll == 0 can - // never get here. - let at_end = gui.last_scroll_y >= max_scroll - BOTTOM_SLACK; - let scroll_y = if at_end { - f32::INFINITY - } else { - gui.last_scroll_y - }; - gui.section = active_section_for(scroll_y, &offsets); - Task::none() - } - }, - Message::Search(query) => { - // D4: emptying the search re-shows the one-pager; land it back - // on the offset tracked before the search began. - let restore = !gui.search.trim().is_empty() && query.trim().is_empty(); - gui.search = query; - if restore { - restore_scroll(gui) - } else { - Task::none() - } - } - Message::KeyPressed(event) => shortcut(gui, event), - Message::PickerToggle(target) => { - gui.picker = if gui.picker == Some(target) { - None - } else { - Some(target) - }; - gui.picker_drag = false; - Task::none() - } - // Color changes from every editor (hex field, RGB sliders, picker - // drags) funnel through the same apply path. Invalid hex is kept as - // a per-editor draft so the text input doesn't snap back mid-typing; - // only valid values reach the mirror and the daemon. - Message::ColorHex(target, hex) => match hex.parse::() { - Ok(value) => { - gui.drafts.remove(&target); - ColorTarget::set_field(target, &mut gui.config, value); - let command = target.command(value); - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } - Err(_) => { - gui.drafts.insert(target, hex); - Task::none() - } - }, - Message::NumText(key, raw) => match raw.trim().parse::() { - // Valid and inside the daemon's bounds: commit immediately. - // Anything else (empty, half-typed, out of range) stays as a - // draft so the input doesn't snap back while typing. - Ok(v) if num_in_bounds(key, v) => apply_num(gui, key, v), - _ => { - gui.num_drafts.insert(key, raw); - Task::none() - } - }, - Message::NumDrag(key, v) => { - let (min, max) = key.num_bounds().expect("slider keys are numeric"); - apply_num(gui, key, (v as i64).clamp(min, max)) - } - Message::NumReset(key) => { - let Value::Num(default) = key.value_of(&Config::default()) else { - unreachable!("number_row only renders numeric keys"); - }; - apply_num(gui, key, default) - } - Message::ColorPart(target, part, v) => { - let current = ColorTarget::field(target, &gui.config).rgb(); - let mut bytes = current; - if let Some(slot) = bytes.get_mut(part as usize) { - *slot = (v * 255.0).round() as u8; - } - let value = HexColor::from_rgb8(bytes[0], bytes[1], bytes[2]); - update(gui, Message::ColorHex(target, value.to_string())) - } - Message::SvPress(target) => { - gui.picker_drag = true; - let p = gui.picker_pos; - apply_sv(gui, target, p) - } - Message::SvMove(target, p) => { - gui.picker_pos = p; - if gui.picker_drag { - apply_sv(gui, target, p) - } else { - Task::none() - } - } - Message::HuePress(target) => { - gui.picker_drag = true; - let p = gui.picker_pos; - apply_hue(gui, target, p) - } - Message::HueMove(target, p) => { - gui.picker_pos = p; - if gui.picker_drag { - apply_hue(gui, target, p) - } else { - Task::none() - } - } - Message::PickerRelease => { - gui.picker_drag = false; - Task::none() - } - Message::SetFlag(key, v) => { - let command = Command::Set(key, Value::Flag(v)); - if key == Key::ShowOnFullscreen { - gui.config.show_on_fullscreen = v; - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } else { - // The daemon decides persistence with its pre-apply autosave - // value; capture ours before the optimistic mirror flips too. - let persists = hyprlay_core::domain::should_persist(&command, gui.config.auto_save); - command.clone().apply_config(&mut gui.config); - if !persists { - gui.dirty = true; - } - Task::perform(send(command.to_string()), Message::Applied) - } - } - Message::AuthClientId(id) => { - gui.auth_client_id = id; - Task::none() - } - Message::AuthClientSecret(secret) => { - gui.auth_client_secret = secret; - Task::none() - } - Message::AuthApply => { - // Credentials deliberately bypass the ctl protocol (secrets - // must never travel the socket): they go straight to auth.json, - // and only an opaque "restart" crosses the socket afterwards. - let creds = AppCredentials { - client_id: gui.auth_client_id.trim().to_string(), - client_secret: gui.auth_client_secret.trim().to_string(), - }; - Task::perform(apply_auth_credentials(creds), Message::Applied) - } - command => { - let command = command_for(command); - mark_dirty(gui, &command); - command.clone().apply_config(&mut gui.config); - Task::perform(send(command.to_string()), Message::Applied) - } - } -} - -/// Keyboard shortcuts: Ctrl+S save, Ctrl+R reset section, Ctrl+Shift+R -/// reset all, Ctrl+F search, Ctrl+1..5 jumps to section N (the same path -/// as a sidebar click), Escape clears the search. -fn shortcut(gui: &mut Gui, event: keyboard::Event) -> Task { - let keyboard::Event::KeyPressed { key, modifiers, .. } = event else { - return Task::none(); - }; - if !modifiers.control() { - if matches!(key, keyboard::Key::Named(key::Named::Escape)) && !gui.search.trim().is_empty() - { - gui.search.clear(); - // D4: Esc empties the search, so land the one-pager back on - // its pre-search offset. - return restore_scroll(gui); - } - return Task::none(); - } - let keyboard::Key::Character(ch) = &key else { - return Task::none(); - }; - match ch.to_lowercase().as_str() { - "s" => update(gui, Message::Save), - "r" if modifiers.shift() => update(gui, Message::ResetAll), - "r" => update(gui, Message::ResetSection(gui.section)), - "f" => iced_runtime::widget::operation::focus(widget_id()), - _ => match ch.parse::() { - // Ctrl+1..5 scroll the one-pager to the section's header. - Ok(n) if (1..=Section::ALL.len()).contains(&n) => { - update(gui, Message::Navigate(Section::at(n - 1).unwrap())) - } - _ => Task::none(), - }, - } -} - -fn widget_id() -> iced::widget::Id { - iced::widget::Id::new(SEARCH_ID) -} - -/// Id of the one-page content scrollable — the jump target for navigation -/// and the widget the measure operation reads geometry from. -fn content_scroll_id() -> Id { - Id::new(CONTENT_SCROLL_ID) -} - -/// Commit one numeric value to the mirror and the daemon. The caller has -/// already made sure the value is inside the key's bounds. -fn apply_num(gui: &mut Gui, key: Key, value: i64) -> Task { - gui.num_drafts.remove(&key); - let command = Command::Set(key, Value::Num(value)); - mark_dirty(gui, &command); - command.clone().apply_config(&mut gui.config); - Task::perform(send(command.to_string()), Message::Applied) -} - -/// Mirror of the daemon's persistence rule: a change is "unsaved" exactly -/// when the daemon would not have persisted it. Decided with the -/// pre-application autosave value — the same one the daemon uses — so -/// flipping auto-save itself never leaves a phantom badge. -fn mark_dirty(gui: &mut Gui, command: &Command) { - if !hyprlay_core::domain::should_persist(command, gui.config.auto_save) { - gui.dirty = true; - } -} - -fn num_in_bounds(key: Key, v: i64) -> bool { - key.num_bounds() - .is_some_and(|(min, max)| v >= min && v <= max) -} - -/// Commands that bring `live` back to `saved`, one per differing key. -/// Used by "clear changes"; empty when there is nothing to revert. Walking -/// the shared [`Key`] table means a newly added setting can never be -/// forgotten here — it shows up in the diff the moment it exists. -fn revert_commands(live: &Config, saved: &Config) -> Vec { - Key::ALL - .into_iter() - .filter(|k| k.value_of(live) != k.value_of(saved)) - .map(|k| Command::Set(k, k.value_of(saved))) - .collect() -} - -/// Turn a GUI interaction into its control-socket command. The local config -/// mirror is the same [`Command::apply_config`] the daemon runs, so both -/// sides can never disagree about what a setting change means. -fn command_for(message: Message) -> Command { - match message { - Message::Position(h, v) => Command::Set(Key::Position, Value::Corner(corner_of(h, v))), - // Rides the generic apply path like Position: mirror locally, send - // the same wire command the CLI would. - Message::Anchor(mode) => Command::Set(Key::Anchor, Value::Anchor(mode)), - Message::SetFlag(..) => unreachable!("flags are handled directly in update"), - // Handled directly in `update`; unreachable here. - Message::NumText(..) - | Message::NumDrag(..) - | Message::NumReset(_) - | Message::ColorPart(..) - | Message::ColorHex(..) - | Message::PickerToggle(..) - | Message::SvPress(..) - | Message::SvMove(..) - | Message::HuePress(..) - | Message::HueMove(..) - | Message::PickerRelease - | Message::Palette(_) - | Message::Navigate(_) - | Message::Scrolled(_) - | Message::Measured { .. } - | Message::Search(_) - | Message::KeyPressed(_) - | Message::Save - | Message::ClearChanges - | Message::ResetAll - | Message::ResetSection(_) - | Message::SwitchMonitor(_) - | Message::Monitors(_) - | Message::Applied(_) - | Message::RefreshStatus - | Message::ToggleDaemon - | Message::ToggleResult(_) - | Message::AuthClientId(_) - | Message::AuthClientSecret(_) - | Message::AuthApply => unreachable!("handled before command_for"), - } -} - -// --------------------------------------------------------------------------- -// One-page navigation: measure, jump, scrollspy -// --------------------------------------------------------------------------- - -/// Half a section-header height: how close a header must be to the top of -/// the viewport before the scrollspy names its section. Small enough that -/// the highlight only moves once a header actually arrives, big enough -/// that a landed jump — which parks the header exactly at the top — keeps -/// its own highlight. -const SECTION_EPSILON: f32 = 16.0; - -/// Slack around the measured maximum scroll within which the page counts -/// as scrolled to its end. -const BOTTOM_SLACK: f32 = 1.0; - -/// The section whose header sits at or above `scroll_y` — the sidebar -/// highlight for that scroll position: the last section whose measured -/// header offset is within `scroll_y + SECTION_EPSILON`. The caller -/// passes [`f32::INFINITY`] once the page has scrolled to its end, -/// because the last header can never reach the viewport top itself. -/// `offsets` must be the headers measured in [`Section::ALL`] order — -/// one offset per section, at the same index. -fn active_section_for(scroll_y: f32, offsets: &[f32; Section::ALL.len()]) -> Section { - let mut active = Section::ALL[0]; - for (index, offset) in offsets.iter().enumerate() { - if *offset > scroll_y + SECTION_EPSILON { - break; - } - active = Section::ALL[index]; - } - active -} - -/// Where `header` sits inside the scrollable content, in px below the -/// content's top. Both rects are window-space layout bounds, so the -/// current scroll translation appears in both and cancels out. -fn offset_within_content(header_bounds: Rectangle, content_bounds: Rectangle) -> f32 { - header_bounds.y - content_bounds.y -} - -/// Measure the one-page content and report back as [`Message::Measured`]. -/// Widget operations run against the layout built from the very latest -/// state, so the offsets are fresh even right after a search-clear -/// re-render or a picker expansion changed heights. -fn measure_sections(jump: Option
) -> Task { - iced_runtime::task::widget(MeasureSections { - jump, - content: None, - offsets: [0.0; Section::ALL.len()], - }) -} - -/// Scroll the one-page content so `y` px into it sit at the viewport top; -/// the horizontal offset is left alone. -fn scroll_content_to(y: f32) -> Task { - iced_runtime::widget::operation::scroll_to( - content_scroll_id(), - iced::widget::scrollable::AbsoluteOffset { - x: None, - y: Some(y), - }, - ) -} - -/// Scroll the one-pager so `section`'s header sits at the top of the -/// viewport. `offsets` must come from a fresh measurement (see -/// [`measure_sections`]); out-of-range targets clamp inside the -/// scrollable. -fn scroll_to_section(section: Section, offsets: [f32; Section::ALL.len()]) -> Task { - scroll_content_to(offsets[section.index()]) -} - -/// D4: land the one-pager back on the offset tracked before the search -/// page replaced it. While the search page was up, nothing reported -/// Scrolled, so [`Gui::last_scroll_y`] still holds that position. -fn restore_scroll(gui: &Gui) -> Task { - scroll_content_to(gui.last_scroll_y) -} - -/// Geometry the measure operation learns about the one-page scrollable. -#[derive(Debug, Clone, Copy)] -struct ContentMeasure { - viewport: Rectangle, - content: Rectangle, -} - -impl ContentMeasure { - /// How far the content can scroll at all. - fn max_scroll(self) -> f32 { - (self.content.height - self.viewport.height).max(0.0) - } -} - -/// Traverses the widget tree once, recording the one-page scrollable's -/// geometry and every section header's offset within the content, then -/// delivers them as [`Message::Measured`]. `jump` rides along so the -/// receiver knows whether to scroll (navigation) or re-derive the -/// highlight (scrollspy). When the one-pager is not in the tree (the -/// search page is up), the operation produces nothing. -struct MeasureSections { - jump: Option
, - content: Option, - offsets: [f32; Section::ALL.len()], -} - -impl Operation for MeasureSections { - fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { - operate(self); - } - - fn scrollable( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - content_bounds: Rectangle, - _translation: Vector, - _state: &mut dyn Scrollable, - ) { - if id == Some(&content_scroll_id()) { - self.content = Some(ContentMeasure { - viewport: bounds, - content: content_bounds, - }); - } - } - - fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { - // The scrollable hook fires before its children are traversed, so - // the content geometry is always known by the time a header anchor - // is visited. - let Some(content) = self.content else { - return; - }; - for (index, section) in Section::ALL.into_iter().enumerate() { - if id == Some(&Id::new(section.anchor_id())) { - self.offsets[index] = offset_within_content(bounds, content.content); - } - } - } - - fn finish(&self) -> Outcome { - let Some(content) = self.content else { - return Outcome::None; - }; - Outcome::Some(Message::Measured { - offsets: self.offsets, - max_scroll: content.max_scroll(), - jump: self.jump, - }) - } -} - -// --------------------------------------------------------------------------- -// View -// --------------------------------------------------------------------------- - -fn view(gui: &Gui) -> Element<'_, Message> { - let content = if gui.search.trim().is_empty() { - settings_page(gui) - } else { - search_page(gui) - }; - - column![ - header(gui), - container(row![sidebar(gui), content]).height(Length::Fill), - status_bar(gui), - ] - .width(Length::Fill) - .height(Length::Fill) - .into() -} - -/// Title, search box, and global actions on the darkest strip. -fn header(gui: &Gui) -> Element<'_, Message> { - // "Clear changes" only does something while the runtime config differs - // from disk; a disabled press target communicates that at a glance. - let mut clear = button(text("Clear changes")).style(plain_style()); - if gui.dirty { - clear = clear.on_press(Message::ClearChanges); - } - container( - row![ - text("hyprlay").size(14).color(BRIGHT), - text_input("Search settings… Ctrl+F", &gui.search) - .id(widget_id()) - .on_input(Message::Search) - .size(13) - .padding([4, 8]), - clear, - button(text("Reset all")) - .on_press(Message::ResetAll) - .style(plain_style()), - button(text("Save")) - .on_press(Message::Save) - .style(primary_style(gui.dirty)), - ] - .spacing(10) - .align_y(Alignment::Center), - ) - .padding([8, 12]) - .width(Length::Fill) - .style(panel(HEADER_BG)) - .into() -} - -/// Section navigation plus the shortcut cheat-sheet. -fn sidebar(gui: &Gui) -> Element<'_, Message> { - let mut nav = column![].spacing(4); - for (i, s) in Section::ALL.iter().enumerate() { - let selected = gui.section == *s && gui.search.trim().is_empty(); - nav = nav.push( - button( - row![ - text(s.name().to_string()).size(13), - iced::widget::Space::new().width(Length::Fill), - text(format!("Ctrl+{}", i + 1)).size(9).color(MUTED), - ] - .align_y(Alignment::Center) - .width(Length::Fill), - ) - .on_press(Message::Navigate(*s)) - .width(Length::Fill) - .style(nav_style(selected)), - ); - } - let hints = - "\nCtrl+S save\nCtrl+R reset section\nCtrl+F search\nEsc clear search"; - let col = column![ - nav, - iced::widget::Space::new().height(Length::Fill), - text(format!("shortcuts{hints}")).size(10).color(MUTED), - ] - .spacing(8); - container(col) - .width(Length::Fixed(160.0)) - .height(Length::Fill) - .padding([10, 8]) - .style(panel(SIDEBAR_BG)) - .into() -} - -fn status_bar(gui: &Gui) -> Element<'_, Message> { - let unsaved = if gui.dirty { - text("● unsaved").size(11).color(AMBER) - } else { - text("").size(11) - }; - container( - row![ - unsaved, - daemon_toggle(gui), - text("daemon").size(10).color(MUTED), - text(brief_status(gui.daemon_state.text())).size(11), - iced::widget::Space::new().width(Length::Fill), - text("last change").size(10).color(MUTED), - text(gui.last_reply.clone()).size(11).color(REPLY_GREEN), - ] - .spacing(8) - .align_y(Alignment::Center), - ) - .padding([6, 12]) - .width(Length::Fill) - .style(panel(HEADER_BG)) - .into() -} - -/// Bottom-left Start/Stop control. Disabled (no press target) while no -/// probe has answered yet, mirroring how "Clear changes" disables itself. -fn daemon_toggle(gui: &Gui) -> Element<'_, Message> { - let mut toggle = button(text(gui.daemon_state.label()).size(11)).style(plain_style()); - if gui.daemon_state.toggle().is_some() { - toggle = toggle.on_press(Message::ToggleDaemon); - } - toggle.into() -} - -/// "status=connected channel=ngobrol 3 participants=2 …" → -/// "connected · ngobrol 3". Parsing goes through the shared -/// [`StatusFields`]; channel names may contain spaces, which its -/// marker-slice handles. -fn brief_status(full: &str) -> String { - match StatusFields::parse_wire(full) { - Some(fields) if !fields.channel.is_empty() => { - format!("{} · {}", fields.status_word, fields.channel) - } - Some(fields) => fields.status_word.to_string(), - None => full.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn search_matches_label_tip_and_section_name() { - let field = fields::Field { - section: Section::Position, - label: "offset x", - tip: "Horizontal distance in px from the anchored screen edge.", - render: fields::f_offset_x, - }; - assert!(fields::search_matches(&field, "offset")); - assert!(fields::search_matches(&field, "horizontal")); - assert!(fields::search_matches(&field, "POSITION")); - assert!(!fields::search_matches(&field, "avatar")); - assert!(!fields::search_matches(&field, "")); - } - - #[test] - fn sections_map_one_to_one_onto_config_groups() { - use hyprlay_core::domain::Group; - // Only config-backed sections participate; Connection has no group. - let config_backed: Vec<_> = Section::ALL - .into_iter() - .filter_map(|section| section.group().map(|group| (section, group))) - .collect(); - assert_eq!(config_backed.len(), Group::ALL.len()); - for ((section, group), expected) in config_backed.into_iter().zip(Group::ALL) { - // The reset button sends one ResetGroup per GUI section; if a - // section ever fails to map, its fields could never be reset. - assert_eq!(group, expected); - assert_eq!( - format!("{expected}"), - section.name().to_lowercase(), - "section {} diverged from group {}", - section.name(), - group - ); - } - } - - /// Exactly one section is exempt from the reset machinery, and its name - /// must stay stable because the shortcut hints and search rely on it. - #[test] - fn every_section_except_connection_maps_to_a_group() { - for section in Section::ALL { - match section.group() { - Some(_) => assert_ne!(section.name(), "Connection"), - None => assert_eq!(section.name(), "Connection"), - } - } - } - - #[test] - fn every_field_has_a_nonempty_tooltip() { - for f in fields::FIELDS { - assert!(!f.tip.is_empty(), "field {} needs a tooltip", f.label); - assert!(!f.label.is_empty()); - } - } - - #[test] - fn every_section_has_fields() { - for s in Section::ALL { - assert!( - fields::FIELDS.iter().any(|f| f.section == s), - "section {} has no fields", - s.name() - ); - } - } - - /// Click-through was removed; no field may render it again. - #[test] - fn click_through_is_gone_from_the_field_registry() { - assert!(!fields::FIELDS.iter().any(|f| f.label.contains("click"))); - } - - #[test] - fn anchor_field_is_registered_in_the_position_section() { - let field = fields::FIELDS - .iter() - .find(|f| f.label == "anchor") - .expect("anchor field registered"); - assert_eq!(field.section, Section::Position); - } - - #[test] - fn anchor_setting_roundtrips_through_apply_and_revert() { - // The exact Command path the GUI's generic change pipeline drives. - let mut live = Config::default(); - let pin_bottom = Command::Set( - Key::Anchor, - Value::Anchor(hyprlay_core::config::AnchorMode::Bottom), - ); - pin_bottom.clone().apply_config(&mut live); - assert_eq!(live.anchor, hyprlay_core::config::AnchorMode::Bottom); - - // Reverting mirrors what "clear changes" replays: read the saved - // value back through the shared table and re-apply it. - let saved = Config::default(); - let revert = Command::Set(Key::Anchor, Key::Anchor.value_of(&saved)); - revert.apply_config(&mut live); - assert_eq!(live.anchor, saved.anchor); - } - - #[test] - fn brief_status_keeps_multiword_channel_names_intact() { - let full = "status=connected channel=ngobrol 3 participants=2 rtl=on monitor=eDP-1"; - assert_eq!(brief_status(full), "connected · ngobrol 3"); - } - - #[test] - fn brief_status_without_channel_falls_back_to_connection_word() { - assert_eq!(brief_status("status=disconnected"), "disconnected"); - assert_eq!(brief_status("connecting…"), "connecting…"); - // Empty channel value (malformed Discord payload only): the old - // code printed "connected · " with a trailing separator; the new - // code drops it. Pinned here so the tightening stays deliberate. - assert_eq!( - brief_status("status=connected channel= participants=0"), - "connected" - ); - } - - #[test] - fn key_sets_use_the_cli_wire_names() { - use hyprlay_core::config::OFFSETS; - assert_eq!( - Command::Set(Key::Opacity, Value::Num(42)).to_string(), - "set opacity 42" - ); - assert_eq!( - Command::Set(Key::OffsetX, Value::Num(-12)).to_string(), - "set offset-x -12" - ); - assert_eq!( - Command::Set(Key::TalkingOnly, Value::Flag(true)).to_string(), - "set talking-only on" - ); - assert!( - (OFFSETS.min as i64..=OFFSETS.max as i64).contains(&-12), - "test value must stay inside the shared bounds" - ); - } - - #[test] - fn every_numeric_key_has_sane_bounds() { - for key in Key::ALL { - if let Some((min, max)) = key.num_bounds() { - assert!(min <= max, "{} has an inverted range", key.name()); - } else { - // Non-numeric keys must not pretend to have slider bounds. - assert!( - !key.slider_bounds(&Config::default()).is_some() - || matches!( - key, - Key::OffsetX - | Key::OffsetY - | Key::Width - | Key::Scale - | Key::AvatarSize - | Key::TextSize - | Key::Spacing - | Key::MaxName - | Key::Opacity - | Key::AvatarOpacity - | Key::TextOpacity - | Key::BoxOpacity - ), - "{} renders a slider without numeric bounds", - key.name() - ); - } - } - } - - #[test] - fn revert_commands_do_nothing_when_configs_match() { - let cfg = Config::default(); - assert!(revert_commands(&cfg, &cfg).is_empty()); - } - - /// Realistic header offsets for the one-page view: five sections - /// stacked downward, the first header just below the page's top - /// padding, later ones several hundred px apart. - fn spy_offsets() -> [f32; Section::ALL.len()] { - [8.0, 900.0, 1500.0, 2100.0, 2600.0] - } - - /// At the top of the page the first header is inside the epsilon, so - /// Position is highlighted; halfway into a section the highlight still - /// names that section. - #[test] - fn scrollspy_names_the_section_under_the_top_of_the_viewport() { - let offsets = spy_offsets(); - assert_eq!(active_section_for(0.0, &offsets), Section::Position); - assert_eq!(active_section_for(1000.0, &offsets), Section::Layout); - assert_eq!(active_section_for(1900.0, &offsets), Section::Opacity); - assert_eq!(active_section_for(2400.0, &offsets), Section::Colors); - } - - /// A header takes over the highlight once the viewport top is within - /// half a header height of it — this is what keeps a landed jump (which - /// parks the header exactly at the top) on its own section. 60 px above - /// the Layout header the highlight must still be Position; 10 px above - /// it, Layout already wins. - #[test] - fn scrollspy_flips_while_a_header_is_half_a_header_away() { - let offsets = spy_offsets(); - assert_eq!(active_section_for(840.0, &offsets), Section::Position); - assert_eq!(active_section_for(890.0, &offsets), Section::Layout); - } - - /// The last section's header can never reach the viewport top (the - /// Connection section is shorter than the viewport), so once the page - /// is scrolled to its end the caller passes INFINITY and the highlight - /// must clamp to Connection instead of staying on Colors. - #[test] - fn scrollspy_clamps_to_connection_at_the_end_of_the_page() { - let offsets = spy_offsets(); - // Without the clamp, a bottom scroll (~2200 here) would highlight - // Colors although the user is looking at Connection. - assert_eq!(active_section_for(2200.0, &offsets), Section::Colors); - assert_eq!( - active_section_for(f32::INFINITY, &offsets), - Section::Connection - ); - } - - /// A page could in principle start with a tall top padding; before the - /// first measurement lands or before any header qualifies, the - /// highlight must fall back to the first section, never panic or wrap. - #[test] - fn scrollspy_falls_back_to_the_first_section_at_the_top() { - let offsets = [100.0, 900.0, 1500.0, 2100.0, 2600.0]; - assert_eq!(active_section_for(0.0, &offsets), Section::Position); - } - - /// Header offsets are read from window-space layout bounds while the - /// page may be scrolled; the jump math depends on the difference - /// between the two rects being independent of that translation. - #[test] - fn header_offset_is_its_distance_below_the_content_and_ignores_scroll() { - let content = Rectangle::new(Point::ORIGIN, iced::Size::new(600.0, 4000.0)); - let header = Rectangle::new(Point::new(16.0, 908.0), iced::Size::new(568.0, 30.0)); - assert_eq!(offset_within_content(header, content), 908.0); - - // The same layout scrolled down by 250 px: both rects move, the - // offset within the content must not. - let scrolled_content = Rectangle::new(Point::new(16.0, -250.0), content.size()); - let scrolled_header = Rectangle::new(Point::new(32.0, 658.0), header.size()); - assert_eq!( - offset_within_content(scrolled_header, scrolled_content), - 908.0 - ); - } - - /// D3: a sidebar click or Ctrl+1..5 while a search is up first drops - /// the query (returning to the one-page view) and shows the target - /// section's highlight immediately, without waiting for the measure - /// round-trip. - #[test] - fn navigating_while_searching_clears_the_search_and_sets_the_section() { - let mut gui = gui_with_search("avatar"); - // The returned Task carries the measure-then-jump round-trip; the - // state transition is what is asserted here. - let _ = update(&mut gui, Message::Navigate(Section::Colors)); - assert!(gui.search.is_empty()); - assert_eq!(gui.section, Section::Colors); - } - - /// D4 mechanism: the restore on search-clear scrolls back to whatever - /// offset the scrollspy last tracked, so scrolling must keep that value - /// current, and emptying the search must not clobber it. - #[test] - fn scrolling_tracks_the_offset_that_the_search_restore_will_use() { - let mut gui = gui_with_search("dim"); - // Both transitions return layout/scroll Tasks; only the tracked - // state matters here. - let _ = update(&mut gui, Message::Scrolled(412.5)); - assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); - - let _ = update(&mut gui, Message::Search(String::new())); - assert!(gui.search.is_empty()); - assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); - } - - /// Minimal `Gui` for state-transition tests: `boot()` touches the real - /// config file, so build the struct with test values instead. Only the - /// navigation fields matter here. - fn gui_with_search(query: &str) -> Gui { - Gui { - config: Config::default(), - drafts: HashMap::new(), - num_drafts: HashMap::new(), - last_reply: String::new(), - daemon_state: daemon::DaemonState::Connecting, - auto_start: daemon::AutoStart::watching(), - control: Arc::new(crate::platform::service::SystemControl), - dirty: false, - monitors: Vec::new(), - section: Section::Position, - search: query.to_string(), - last_scroll_y: 0.0, - picker: None, - picker_drag: false, - picker_pos: Point::ORIGIN, - auth_client_id: String::new(), - auth_client_secret: String::new(), - } - } - - #[test] - fn revert_commands_cover_every_differing_key_once() { - use hyprlay_core::config::HorizontalAnchor as H; - use hyprlay_core::config::VerticalAnchor as V; - // show_own_user defaults to true, so flipping it off is a real diff. - let saved = Config { - horizontal: H::Right, - vertical: V::Top, // top-right corner - rtl: true, - offset_x: 40, - opacity: 70, - width: 500, - show_own_user: false, - monitor: Some("DP-2".into()), - speaking_color: "#00ff00".parse().unwrap(), - ..Config::default() - }; - - let cmds = revert_commands(&Config::default(), &saved); - for expected in [ - "set position top-right", - "set rtl on", - "set offset-x 40", - "set opacity 70", - "set width 500", - "set own-user off", - "set monitor DP-2", - ] { - assert!( - cmds.iter().any(|c| c.to_string() == expected), - "missing revert command {expected}" - ); - } - assert!( - cmds.iter() - .any(|c| c.to_string().starts_with("set speaking-color ")) - ); - // Exactly one command per changed key — no redundant spam. - assert_eq!(cmds.len(), 8, "unexpected extra commands: {cmds:?}"); - } -} diff --git a/src/gui/picker.rs b/src/gui/picker.rs index 3ccfdf2..de686f9 100644 --- a/src/gui/picker.rs +++ b/src/gui/picker.rs @@ -32,7 +32,7 @@ use iced::widget::text_input; use super::Gui; use super::Message; use super::fields::reset_button; -use super::update; +use super::update::update; fn iced_color(rgb: Rgb) -> Color { Color::from_rgb(rgb.r, rgb.g, rgb.b) diff --git a/src/gui/scroll.rs b/src/gui/scroll.rs new file mode 100644 index 0000000..9469a8b --- /dev/null +++ b/src/gui/scroll.rs @@ -0,0 +1,261 @@ +//! One-page navigation: measure the content layout, jump to a section, +//! and keep the sidebar highlight (scrollspy) on the section under the +//! viewport top. Also owns the two widget ids shared across the GUI: the +//! header's search input and the one-page content scrollable. + +use iced::Rectangle; +use iced::Task; +use iced::Vector; +use iced::widget::Id; +use iced_runtime::core::widget::Operation; +use iced_runtime::core::widget::operation::Outcome; +use iced_runtime::core::widget::operation::scrollable::Scrollable; + +use super::Gui; +use super::Message; +use super::fields::CONTENT_SCROLL_ID; +use super::fields::SEARCH_ID; +use super::fields::Section; + +pub(super) fn widget_id() -> iced::widget::Id { + iced::widget::Id::new(SEARCH_ID) +} + +/// Id of the one-page content scrollable — the jump target for navigation +/// and the widget the measure operation reads geometry from. +fn content_scroll_id() -> Id { + Id::new(CONTENT_SCROLL_ID) +} + +/// Half a section-header height: how close a header must be to the top of +/// the viewport before the scrollspy names its section. Small enough that +/// the highlight only moves once a header actually arrives, big enough +/// that a landed jump — which parks the header exactly at the top — keeps +/// its own highlight. +const SECTION_EPSILON: f32 = 16.0; + +/// Slack around the measured maximum scroll within which the page counts +/// as scrolled to its end. +pub(super) const BOTTOM_SLACK: f32 = 1.0; + +/// The section whose header sits at or above `scroll_y` — the sidebar +/// highlight for that scroll position: the last section whose measured +/// header offset is within `scroll_y + SECTION_EPSILON`. The caller +/// passes [`f32::INFINITY`] once the page has scrolled to its end, +/// because the last header can never reach the viewport top itself. +/// `offsets` must be the headers measured in [`Section::ALL`] order — +/// one offset per section, at the same index. +pub(super) fn active_section_for(scroll_y: f32, offsets: &[f32; Section::ALL.len()]) -> Section { + let mut active = Section::ALL[0]; + for (index, offset) in offsets.iter().enumerate() { + if *offset > scroll_y + SECTION_EPSILON { + break; + } + active = Section::ALL[index]; + } + active +} + +/// Where `header` sits inside the scrollable content, in px below the +/// content's top. Both rects are window-space layout bounds, so the +/// current scroll translation appears in both and cancels out. +fn offset_within_content(header_bounds: Rectangle, content_bounds: Rectangle) -> f32 { + header_bounds.y - content_bounds.y +} + +/// Measure the one-page content and report back as [`Message::Measured`]. +/// Widget operations run against the layout built from the very latest +/// state, so the offsets are fresh even right after a search-clear +/// re-render or a picker expansion changed heights. +pub(super) fn measure_sections(jump: Option
) -> Task { + iced_runtime::task::widget(MeasureSections { + jump, + content: None, + offsets: [0.0; Section::ALL.len()], + }) +} + +/// Scroll the one-page content so `y` px into it sit at the viewport top; +/// the horizontal offset is left alone. +fn scroll_content_to(y: f32) -> Task { + iced_runtime::widget::operation::scroll_to( + content_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { + x: None, + y: Some(y), + }, + ) +} + +/// Scroll the one-pager so `section`'s header sits at the top of the +/// viewport. `offsets` must come from a fresh measurement (see +/// [`measure_sections`]); out-of-range targets clamp inside the +/// scrollable. +pub(super) fn scroll_to_section( + section: Section, + offsets: [f32; Section::ALL.len()], +) -> Task { + scroll_content_to(offsets[section.index()]) +} + +/// D4: land the one-pager back on the offset tracked before the search +/// page replaced it. While the search page was up, nothing reported +/// Scrolled, so [`Gui::last_scroll_y`] still holds that position. +pub(super) fn restore_scroll(gui: &Gui) -> Task { + scroll_content_to(gui.last_scroll_y) +} + +/// Geometry the measure operation learns about the one-page scrollable. +#[derive(Debug, Clone, Copy)] +struct ContentMeasure { + viewport: Rectangle, + content: Rectangle, +} + +impl ContentMeasure { + /// How far the content can scroll at all. + fn max_scroll(self) -> f32 { + (self.content.height - self.viewport.height).max(0.0) + } +} + +/// Traverses the widget tree once, recording the one-page scrollable's +/// geometry and every section header's offset within the content, then +/// delivers them as [`Message::Measured`]. `jump` rides along so the +/// receiver knows whether to scroll (navigation) or re-derive the +/// highlight (scrollspy). When the one-pager is not in the tree (the +/// search page is up), the operation produces nothing. +struct MeasureSections { + jump: Option
, + content: Option, + offsets: [f32; Section::ALL.len()], +} + +impl Operation for MeasureSections { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn scrollable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + content_bounds: Rectangle, + _translation: Vector, + _state: &mut dyn Scrollable, + ) { + if id == Some(&content_scroll_id()) { + self.content = Some(ContentMeasure { + viewport: bounds, + content: content_bounds, + }); + } + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + // The scrollable hook fires before its children are traversed, so + // the content geometry is always known by the time a header anchor + // is visited. + let Some(content) = self.content else { + return; + }; + for (index, section) in Section::ALL.into_iter().enumerate() { + if id == Some(&Id::new(section.anchor_id())) { + self.offsets[index] = offset_within_content(bounds, content.content); + } + } + } + + fn finish(&self) -> Outcome { + let Some(content) = self.content else { + return Outcome::None; + }; + Outcome::Some(Message::Measured { + offsets: self.offsets, + max_scroll: content.max_scroll(), + jump: self.jump, + }) + } +} + +#[cfg(test)] +mod tests { + use iced::Point; + + use super::*; + + /// Realistic header offsets for the one-page view: five sections + /// stacked downward, the first header just below the page's top + /// padding, later ones several hundred px apart. + fn spy_offsets() -> [f32; Section::ALL.len()] { + [8.0, 900.0, 1500.0, 2100.0, 2600.0] + } + + /// At the top of the page the first header is inside the epsilon, so + /// Position is highlighted; halfway into a section the highlight still + /// names that section. + #[test] + fn scrollspy_names_the_section_under_the_top_of_the_viewport() { + let offsets = spy_offsets(); + assert_eq!(active_section_for(0.0, &offsets), Section::Position); + assert_eq!(active_section_for(1000.0, &offsets), Section::Layout); + assert_eq!(active_section_for(1900.0, &offsets), Section::Opacity); + assert_eq!(active_section_for(2400.0, &offsets), Section::Colors); + } + + /// A header takes over the highlight once the viewport top is within + /// half a header height of it — this is what keeps a landed jump (which + /// parks the header exactly at the top) on its own section. 60 px above + /// the Layout header the highlight must still be Position; 10 px above + /// it, Layout already wins. + #[test] + fn scrollspy_flips_while_a_header_is_half_a_header_away() { + let offsets = spy_offsets(); + assert_eq!(active_section_for(840.0, &offsets), Section::Position); + assert_eq!(active_section_for(890.0, &offsets), Section::Layout); + } + + /// The last section's header can never reach the viewport top (the + /// Connection section is shorter than the viewport), so once the page + /// is scrolled to its end the caller passes INFINITY and the highlight + /// must clamp to Connection instead of staying on Colors. + #[test] + fn scrollspy_clamps_to_connection_at_the_end_of_the_page() { + let offsets = spy_offsets(); + // Without the clamp, a bottom scroll (~2200 here) would highlight + // Colors although the user is looking at Connection. + assert_eq!(active_section_for(2200.0, &offsets), Section::Colors); + assert_eq!( + active_section_for(f32::INFINITY, &offsets), + Section::Connection + ); + } + + /// A page could in principle start with a tall top padding; before the + /// first measurement lands or before any header qualifies, the + /// highlight must fall back to the first section, never panic or wrap. + #[test] + fn scrollspy_falls_back_to_the_first_section_at_the_top() { + let offsets = [100.0, 900.0, 1500.0, 2100.0, 2600.0]; + assert_eq!(active_section_for(0.0, &offsets), Section::Position); + } + + /// Header offsets are read from window-space layout bounds while the + /// page may be scrolled; the jump math depends on the difference + /// between the two rects being independent of that translation. + #[test] + fn header_offset_is_its_distance_below_the_content_and_ignores_scroll() { + let content = Rectangle::new(Point::ORIGIN, iced::Size::new(600.0, 4000.0)); + let header = Rectangle::new(Point::new(16.0, 908.0), iced::Size::new(568.0, 30.0)); + assert_eq!(offset_within_content(header, content), 908.0); + + // The same layout scrolled down by 250 px: both rects move, the + // offset within the content must not. + let scrolled_content = Rectangle::new(Point::new(16.0, -250.0), content.size()); + let scrolled_header = Rectangle::new(Point::new(32.0, 658.0), header.size()); + assert_eq!( + offset_within_content(scrolled_header, scrolled_content), + 908.0 + ); + } +} diff --git a/src/gui/update.rs b/src/gui/update.rs new file mode 100644 index 0000000..5b5debb --- /dev/null +++ b/src/gui/update.rs @@ -0,0 +1,496 @@ +//! Update layer: the one flat [`update`] match — the app's dispatch +//! table — plus the keyboard [`shortcut`] dispatcher that feeds it and +//! the async effects its arms spawn off the UI thread. + +use std::sync::Arc; + +use hyprlay_core::config::Config; +use hyprlay_core::config::PALETTES; +use hyprlay_core::config::{self}; +use hyprlay_core::credentials::AppCredentials; +use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::StopPolicy; +use hyprlay_core::daemon_control::Toggle; +use hyprlay_core::domain::Command; +use hyprlay_core::domain::HexColor; +use hyprlay_core::domain::Key; +use hyprlay_core::domain::Value; +use hyprlay_core::status::StatusFields; +use iced::Task; +use iced::keyboard::key; +use iced::keyboard::{self}; + +use super::Gui; +use super::Message; +use super::commands::apply_num; +use super::commands::command_for; +use super::commands::mark_dirty; +use super::commands::num_in_bounds; +use super::commands::revert_commands; +use super::fields::Section; +use super::picker::ColorTarget; +use super::picker::apply_hue; +use super::picker::apply_sv; +use super::scroll::BOTTOM_SLACK; +use super::scroll::active_section_for; +use super::scroll::measure_sections; +use super::scroll::restore_scroll; +use super::scroll::scroll_to_section; +use super::scroll::widget_id; +use super::send; + +pub(super) fn update(gui: &mut Gui, message: Message) -> Task { + match message { + Message::Applied(reply) => { + let reply = reply.trim().to_string(); + // Every reply is a potential probe outcome; only probe outcomes + // actually move the state (see DaemonState::advance) — and + // while the boot auto-start has the wheel, failures hold + // `connecting…` instead of reporting the daemon dead. + let launch = gui.auto_start.observe(&mut gui.daemon_state, &reply); + // `dump` replies with the live runtime config as TOML — adopt it + // so the GUI reflects unsaved daemon state. The [position] + // header marks a dump; any other text is an ordinary reply. Any + // in-flight input drafts are stale after an external reset, so + // drop them too. + if reply.contains("[position]") { + if let Ok(live) = toml::from_str::(&reply) { + gui.config = live; + gui.drafts.clear(); + gui.num_drafts.clear(); + } + } else if reply == "saved" { + gui.dirty = false; + } else if !reply.is_empty() && !StatusFields::is_status_line(&reply) { + // status= replies are consumed by the state chip above; + // everything else is ordinary status-bar traffic. + gui.last_reply = reply; + } + match launch { + Some(toggle) => { + // Opening the GUI brings the daemon up: fire-and-forget + // off the UI thread, through the same DaemonControl path + // as the Start button. This is also why closing the + // window never stops the daemon — nothing here ties its + // lifetime to GUI exit (systemctl owns the unit; the + // fallback spawn detaches into its own process group). + let control = Arc::clone(&gui.control); + Task::perform(run_toggle(control, toggle), Message::ToggleResult) + } + None => Task::none(), + } + } + Message::RefreshStatus => { + Task::perform(send(Command::Status.to_string()), Message::Applied) + } + Message::ToggleDaemon => { + let Some(toggle) = gui.daemon_state.toggle() else { + return Task::none(); + }; + let control = Arc::clone(&gui.control); + Task::perform(run_toggle(control, toggle), Message::ToggleResult) + } + Message::ToggleResult(failure) => { + // The boot bring-up attempt finished either way; stop holding + // the connecting line on its behalf. + gui.auto_start.settled(); + if let Some(text) = failure { + gui.last_reply = text; + } + // Whether it worked is only visible through a fresh probe; do + // not wait for the next 2 s tick. + Task::perform(send(Command::Status.to_string()), Message::Applied) + } + Message::Monitors(monitors) => { + gui.monitors = monitors; + Task::none() + } + Message::Save => { + gui.dirty = false; + Task::perform(send(Command::Save.to_string()), Message::Applied) + } + Message::ClearChanges => { + // Revert the daemon's runtime state to the on-disk config by + // replaying only the fields that actually differ. + let saved = config::load(); + let commands = revert_commands(&gui.config, &saved); + gui.config = saved; + gui.drafts.clear(); + gui.num_drafts.clear(); + gui.dirty = false; + Task::batch( + commands + .into_iter() + .map(|c| Task::perform(send(c.to_string()), Message::Applied)), + ) + } + Message::ResetAll => { + mark_dirty(gui, &Command::ResetAll); + Task::perform(send(Command::ResetAll.to_string()), Message::Applied).chain( + Task::perform(send(Command::Dump.to_string()), Message::Applied), + ) + } + Message::ResetSection(section) => { + // Sections without a config group have nothing to reset; the + // GUI hides their button, so this arm is a defensive no-op. + let Some(group) = section.group() else { + return Task::none(); + }; + let command = Command::ResetGroup(group); + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied).chain(Task::perform( + send(Command::Dump.to_string()), + Message::Applied, + )) + } + // `monitor` is answered by the shell before apply_config runs, so + // the mirror must be updated here or the chip highlight lags behind. + Message::SwitchMonitor(target) => { + let command = Command::Set( + Key::Monitor, + Value::Target(match &target { + None => hyprlay_core::domain::MonitorTarget::Active, + Some(name) => hyprlay_core::domain::MonitorTarget::Named(name.clone()), + }), + ); + gui.config.monitor = target; + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } + Message::Palette(index) => { + let Some(p) = PALETTES.get(index) else { + return Task::none(); + }; + let cmds = [ + Command::Set(Key::SpeakingColor, Value::Color(p.speaking)), + Command::Set(Key::TextColor, Value::Color(p.text)), + Command::Set(Key::BoxColor, Value::Color(p.box_bg)), + ]; + for cmd in &cmds { + cmd.clone().apply_config(&mut gui.config); + } + // All three palette entries are Sets: one decision covers them. + mark_dirty(gui, &cmds[0]); + Task::batch( + cmds.into_iter() + .map(|c| Task::perform(send(c.to_string()), Message::Applied)), + ) + } + Message::Navigate(section) => { + // D3: jumping while searching first returns to the one-page + // view; the `measure_sections` task below runs against the + // layout built after this re-render, so its offsets are fresh. + if !gui.search.trim().is_empty() { + gui.search.clear(); + } + // Immediate highlight — don't make the sidebar wait for the + // measure round-trip. + gui.section = section; + measure_sections(Some(section)) + } + Message::Scrolled(offset_y) => { + // Continuously tracked so a search-clear can restore it (D4); + // nothing reports Scrolled while the search page is up, so the + // value freezes at its pre-search state. + gui.last_scroll_y = offset_y; + measure_sections(None) + } + Message::Measured { + offsets, + max_scroll, + jump, + } => match jump { + Some(section) => scroll_to_section(section, offsets), + None => { + // Scrollspy: at the very end of the page the last header + // can never reach the viewport top (Connection is shorter + // than the viewport), so a bottomed-out scroll maps to + // INFINITY and clamps to the last section. This branch + // only sees a scrollable page: while the content fits its + // viewport no scroll event fires, so max_scroll == 0 can + // never get here. + let at_end = gui.last_scroll_y >= max_scroll - BOTTOM_SLACK; + let scroll_y = if at_end { + f32::INFINITY + } else { + gui.last_scroll_y + }; + gui.section = active_section_for(scroll_y, &offsets); + Task::none() + } + }, + Message::Search(query) => { + // D4: emptying the search re-shows the one-pager; land it back + // on the offset tracked before the search began. + let restore = !gui.search.trim().is_empty() && query.trim().is_empty(); + gui.search = query; + if restore { + restore_scroll(gui) + } else { + Task::none() + } + } + Message::KeyPressed(event) => shortcut(gui, event), + Message::PickerToggle(target) => { + gui.picker = if gui.picker == Some(target) { + None + } else { + Some(target) + }; + gui.picker_drag = false; + Task::none() + } + // Color changes from every editor (hex field, RGB sliders, picker + // drags) funnel through the same apply path. Invalid hex is kept as + // a per-editor draft so the text input doesn't snap back mid-typing; + // only valid values reach the mirror and the daemon. + Message::ColorHex(target, hex) => match hex.parse::() { + Ok(value) => { + gui.drafts.remove(&target); + ColorTarget::set_field(target, &mut gui.config, value); + let command = target.command(value); + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } + Err(_) => { + gui.drafts.insert(target, hex); + Task::none() + } + }, + Message::NumText(key, raw) => match raw.trim().parse::() { + // Valid and inside the daemon's bounds: commit immediately. + // Anything else (empty, half-typed, out of range) stays as a + // draft so the input doesn't snap back while typing. + Ok(v) if num_in_bounds(key, v) => apply_num(gui, key, v), + _ => { + gui.num_drafts.insert(key, raw); + Task::none() + } + }, + Message::NumDrag(key, v) => { + let (min, max) = key.num_bounds().expect("slider keys are numeric"); + apply_num(gui, key, (v as i64).clamp(min, max)) + } + Message::NumReset(key) => { + let Value::Num(default) = key.value_of(&Config::default()) else { + unreachable!("number_row only renders numeric keys"); + }; + apply_num(gui, key, default) + } + Message::ColorPart(target, part, v) => { + let current = ColorTarget::field(target, &gui.config).rgb(); + let mut bytes = current; + if let Some(slot) = bytes.get_mut(part as usize) { + *slot = (v * 255.0).round() as u8; + } + let value = HexColor::from_rgb8(bytes[0], bytes[1], bytes[2]); + update(gui, Message::ColorHex(target, value.to_string())) + } + Message::SvPress(target) => { + gui.picker_drag = true; + let p = gui.picker_pos; + apply_sv(gui, target, p) + } + Message::SvMove(target, p) => { + gui.picker_pos = p; + if gui.picker_drag { + apply_sv(gui, target, p) + } else { + Task::none() + } + } + Message::HuePress(target) => { + gui.picker_drag = true; + let p = gui.picker_pos; + apply_hue(gui, target, p) + } + Message::HueMove(target, p) => { + gui.picker_pos = p; + if gui.picker_drag { + apply_hue(gui, target, p) + } else { + Task::none() + } + } + Message::PickerRelease => { + gui.picker_drag = false; + Task::none() + } + Message::SetFlag(key, v) => { + let command = Command::Set(key, Value::Flag(v)); + if key == Key::ShowOnFullscreen { + gui.config.show_on_fullscreen = v; + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } else { + // The daemon decides persistence with its pre-apply autosave + // value; capture ours before the optimistic mirror flips too. + let persists = hyprlay_core::domain::should_persist(&command, gui.config.auto_save); + command.clone().apply_config(&mut gui.config); + if !persists { + gui.dirty = true; + } + Task::perform(send(command.to_string()), Message::Applied) + } + } + Message::AuthClientId(id) => { + gui.auth_client_id = id; + Task::none() + } + Message::AuthClientSecret(secret) => { + gui.auth_client_secret = secret; + Task::none() + } + Message::AuthApply => { + // Credentials deliberately bypass the ctl protocol (secrets + // must never travel the socket): they go straight to auth.json, + // and only an opaque "restart" crosses the socket afterwards. + let creds = AppCredentials { + client_id: gui.auth_client_id.trim().to_string(), + client_secret: gui.auth_client_secret.trim().to_string(), + }; + Task::perform(apply_auth_credentials(creds), Message::Applied) + } + command => { + let command = command_for(command); + mark_dirty(gui, &command); + command.clone().apply_config(&mut gui.config); + Task::perform(send(command.to_string()), Message::Applied) + } + } +} + +/// Keyboard shortcuts: Ctrl+S save, Ctrl+R reset section, Ctrl+Shift+R +/// reset all, Ctrl+F search, Ctrl+1..5 jumps to section N (the same path +/// as a sidebar click), Escape clears the search. +fn shortcut(gui: &mut Gui, event: keyboard::Event) -> Task { + let keyboard::Event::KeyPressed { key, modifiers, .. } = event else { + return Task::none(); + }; + if !modifiers.control() { + if matches!(key, keyboard::Key::Named(key::Named::Escape)) && !gui.search.trim().is_empty() + { + gui.search.clear(); + // D4: Esc empties the search, so land the one-pager back on + // its pre-search offset. + return restore_scroll(gui); + } + return Task::none(); + } + let keyboard::Key::Character(ch) = &key else { + return Task::none(); + }; + match ch.to_lowercase().as_str() { + "s" => update(gui, Message::Save), + "r" if modifiers.shift() => update(gui, Message::ResetAll), + "r" => update(gui, Message::ResetSection(gui.section)), + "f" => iced_runtime::widget::operation::focus(widget_id()), + _ => match ch.parse::() { + // Ctrl+1..5 scroll the one-pager to the section's header. + Ok(n) if (1..=Section::ALL.len()).contains(&n) => { + update(gui, Message::Navigate(Section::at(n - 1).unwrap())) + } + _ => Task::none(), + }, + } +} + +/// Run one Start/Stop action (systemctl, sibling spawn, or socket quit) off +/// the UI thread, same blocking pattern as [`send`]. +async fn run_toggle(control: Arc, toggle: Toggle) -> Option { + tokio::task::spawn_blocking(move || { + hyprlay_core::daemon_control::execute_toggle(&*control, toggle, StopPolicy::ViaSystemctl) + }) + .await + .unwrap_or_else(|e| Some(format!("error: daemon toggle task failed: {e}"))) +} + +/// Persist own-app credentials off the UI thread, then ask the daemon to +/// restart so it re-runs detect() and picks up the new backend. The +/// returned text lands in the status bar via [`Message::Applied`]. +async fn apply_auth_credentials(creds: AppCredentials) -> String { + // Read before the move: the decision text depends on what was applied. + let cleared = creds.client_id.is_empty() && creds.client_secret.is_empty(); + let saved = tokio::task::spawn_blocking(move || hyprlay_core::credentials::save(&creds)) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e.to_string()))); + match saved { + Ok(()) => { + // The daemon's own reply only confirms delivery; the meaningful + // text for the status bar is ours. + let _ = send("restart".to_string()).await; + if cleared { + "credentials cleared, restarting daemon".to_string() + } else { + "credentials saved, restarting daemon".to_string() + } + } + Err(e) => format!("error: could not write credentials: {e}"), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use iced::Point; + + use super::*; + use crate::gui::daemon::AutoStart; + use crate::gui::daemon::DaemonState; + + /// D3: a sidebar click or Ctrl+1..5 while a search is up first drops + /// the query (returning to the one-page view) and shows the target + /// section's highlight immediately, without waiting for the measure + /// round-trip. + #[test] + fn navigating_while_searching_clears_the_search_and_sets_the_section() { + let mut gui = gui_with_search("avatar"); + // The returned Task carries the measure-then-jump round-trip; the + // state transition is what is asserted here. + let _ = update(&mut gui, Message::Navigate(Section::Colors)); + assert!(gui.search.is_empty()); + assert_eq!(gui.section, Section::Colors); + } + + /// D4 mechanism: the restore on search-clear scrolls back to whatever + /// offset the scrollspy last tracked, so scrolling must keep that value + /// current, and emptying the search must not clobber it. + #[test] + fn scrolling_tracks_the_offset_that_the_search_restore_will_use() { + let mut gui = gui_with_search("dim"); + // Both transitions return layout/scroll Tasks; only the tracked + // state matters here. + let _ = update(&mut gui, Message::Scrolled(412.5)); + assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); + + let _ = update(&mut gui, Message::Search(String::new())); + assert!(gui.search.is_empty()); + assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); + } + + /// Minimal `Gui` for state-transition tests: `boot()` touches the real + /// config file, so build the struct with test values instead. Only the + /// navigation fields matter here. + fn gui_with_search(query: &str) -> Gui { + Gui { + config: Config::default(), + drafts: HashMap::new(), + num_drafts: HashMap::new(), + last_reply: String::new(), + daemon_state: DaemonState::Connecting, + auto_start: AutoStart::watching(), + control: Arc::new(crate::platform::service::SystemControl), + dirty: false, + monitors: Vec::new(), + section: Section::Position, + search: query.to_string(), + last_scroll_y: 0.0, + picker: None, + picker_drag: false, + picker_pos: Point::ORIGIN, + auth_client_id: String::new(), + auth_client_secret: String::new(), + } + } +} diff --git a/src/gui/view.rs b/src/gui/view.rs new file mode 100644 index 0000000..f00b3d7 --- /dev/null +++ b/src/gui/view.rs @@ -0,0 +1,191 @@ +//! View layer: the window composition — header (title, search, global +//! actions), sidebar (section anchors + shortcut cheat-sheet), status bar +//! (unsaved marker, daemon toggle, last reply) — around the section pages +//! that `fields` renders. + +use hyprlay_core::status::StatusFields; +use iced::Alignment; +use iced::Element; +use iced::Length; +use iced::widget::button; +use iced::widget::column; +use iced::widget::container; +use iced::widget::row; +use iced::widget::text; +use iced::widget::text_input; + +use super::Gui; +use super::Message; +use super::fields::Section; +use super::fields::search_page; +use super::fields::settings_page; +use super::scroll::widget_id; +use super::theme::AMBER; +use super::theme::BRIGHT; +use super::theme::HEADER_BG; +use super::theme::MUTED; +use super::theme::REPLY_GREEN; +use super::theme::SIDEBAR_BG; +use super::theme::nav_style; +use super::theme::panel; +use super::theme::plain_style; +use super::theme::primary_style; + +pub(super) fn view(gui: &Gui) -> Element<'_, Message> { + let content = if gui.search.trim().is_empty() { + settings_page(gui) + } else { + search_page(gui) + }; + + column![ + header(gui), + container(row![sidebar(gui), content]).height(Length::Fill), + status_bar(gui), + ] + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Title, search box, and global actions on the darkest strip. +fn header(gui: &Gui) -> Element<'_, Message> { + // "Clear changes" only does something while the runtime config differs + // from disk; a disabled press target communicates that at a glance. + let mut clear = button(text("Clear changes")).style(plain_style()); + if gui.dirty { + clear = clear.on_press(Message::ClearChanges); + } + container( + row![ + text("hyprlay").size(14).color(BRIGHT), + text_input("Search settings… Ctrl+F", &gui.search) + .id(widget_id()) + .on_input(Message::Search) + .size(13) + .padding([4, 8]), + clear, + button(text("Reset all")) + .on_press(Message::ResetAll) + .style(plain_style()), + button(text("Save")) + .on_press(Message::Save) + .style(primary_style(gui.dirty)), + ] + .spacing(10) + .align_y(Alignment::Center), + ) + .padding([8, 12]) + .width(Length::Fill) + .style(panel(HEADER_BG)) + .into() +} + +/// Section navigation plus the shortcut cheat-sheet. +fn sidebar(gui: &Gui) -> Element<'_, Message> { + let mut nav = column![].spacing(4); + for (i, s) in Section::ALL.iter().enumerate() { + let selected = gui.section == *s && gui.search.trim().is_empty(); + nav = nav.push( + button( + row![ + text(s.name().to_string()).size(13), + iced::widget::Space::new().width(Length::Fill), + text(format!("Ctrl+{}", i + 1)).size(9).color(MUTED), + ] + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .on_press(Message::Navigate(*s)) + .width(Length::Fill) + .style(nav_style(selected)), + ); + } + let hints = + "\nCtrl+S save\nCtrl+R reset section\nCtrl+F search\nEsc clear search"; + let col = column![ + nav, + iced::widget::Space::new().height(Length::Fill), + text(format!("shortcuts{hints}")).size(10).color(MUTED), + ] + .spacing(8); + container(col) + .width(Length::Fixed(160.0)) + .height(Length::Fill) + .padding([10, 8]) + .style(panel(SIDEBAR_BG)) + .into() +} + +fn status_bar(gui: &Gui) -> Element<'_, Message> { + let unsaved = if gui.dirty { + text("● unsaved").size(11).color(AMBER) + } else { + text("").size(11) + }; + container( + row![ + unsaved, + daemon_toggle(gui), + text("daemon").size(10).color(MUTED), + text(brief_status(gui.daemon_state.text())).size(11), + iced::widget::Space::new().width(Length::Fill), + text("last change").size(10).color(MUTED), + text(gui.last_reply.clone()).size(11).color(REPLY_GREEN), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([6, 12]) + .width(Length::Fill) + .style(panel(HEADER_BG)) + .into() +} + +/// Bottom-left Start/Stop control. Disabled (no press target) while no +/// probe has answered yet, mirroring how "Clear changes" disables itself. +fn daemon_toggle(gui: &Gui) -> Element<'_, Message> { + let mut toggle = button(text(gui.daemon_state.label()).size(11)).style(plain_style()); + if gui.daemon_state.toggle().is_some() { + toggle = toggle.on_press(Message::ToggleDaemon); + } + toggle.into() +} + +/// "status=connected channel=ngobrol 3 participants=2 …" → +/// "connected · ngobrol 3". Parsing goes through the shared +/// [`StatusFields`]; channel names may contain spaces, which its +/// marker-slice handles. +fn brief_status(full: &str) -> String { + match StatusFields::parse_wire(full) { + Some(fields) if !fields.channel.is_empty() => { + format!("{} · {}", fields.status_word, fields.channel) + } + Some(fields) => fields.status_word.to_string(), + None => full.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brief_status_keeps_multiword_channel_names_intact() { + let full = "status=connected channel=ngobrol 3 participants=2 rtl=on monitor=eDP-1"; + assert_eq!(brief_status(full), "connected · ngobrol 3"); + } + + #[test] + fn brief_status_without_channel_falls_back_to_connection_word() { + assert_eq!(brief_status("status=disconnected"), "disconnected"); + assert_eq!(brief_status("connecting…"), "connecting…"); + // Empty channel value (malformed Discord payload only): the old + // code printed "connected · " with a trailing separator; the new + // code drops it. Pinned here so the tightening stays deliberate. + assert_eq!( + brief_status("status=connected channel= participants=0"), + "connected" + ); + } +} From 155fc661cb921d9be122691019d7fd25137aa645 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Sat, 5 Sep 2026 03:16:46 +0700 Subject: [PATCH 07/12] feat: add the rssprobe RSS sampler to the debug probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hunting the daemon memory regression needs a series, not a btop glance: scripts/rssprobe.sh samples VmRSS plus the VmHWM high-water mark of a PID on an interval into a CSV, with labeled scenario markers appended from a second shell (the mark subcommand). RSS far below HWM means freed-but-retained memory; RSS at HWM means real growth — the doc explains reading that pair and carries the runbook for the hyprlayd scenario series (idle, hover-storm, VC churn). While in docs/dev/debug-probes.md, fix two stale adapter paths (src/adapters/ipc.rs and src/adapters/discord.rs moved under src/daemon/adapters/). --- docs/dev/debug-probes.md | 65 ++++++++++++++++++++++++----- scripts/rssprobe.sh | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 10 deletions(-) create mode 100755 scripts/rssprobe.sh diff --git a/docs/dev/debug-probes.md b/docs/dev/debug-probes.md index bafe37c..f7c8e0a 100644 --- a/docs/dev/debug-probes.md +++ b/docs/dev/debug-probes.md @@ -1,14 +1,16 @@ # Debug probes -Two throwaway-style probes dump raw Discord RPC traffic. They do not +Three probes live here. Two dump raw Discord RPC traffic; they do not import any crate code — they speak the wire protocols directly, so they -keep working (and stay truthful) even when the adapters change. +keep working (and stay truthful) even when the adapters change. The +third is a plain shell sampler for RSS-hunting on a process that does +not look like a leak in the code but does in btop. -They live in their own mini-crate under `scripts/`, deliberately separate -from the main package, so they never build with or get installed by -`hyprlay`. Their targets are examples, not bins: that keeps the repo's -`cargo install --git` scan at exactly one binary package, so bare -installs work. Run them from that directory: +The two Discord probes live in their own mini-crate under `scripts/`, +deliberately separate from the main package, so they never build with or +get installed by `hyprlay`. Their targets are examples, not bins: that +keeps the repo's `cargo install --git` scan at exactly one binary +package, so bare installs work. Run them from that directory: ```sh cd scripts @@ -16,12 +18,55 @@ cargo run --example ipcprobe # unix-socket IPC probe (current transport) cargo run --example wsprobe # historical websocket bridge probe ``` +## Daemon memory sampler (`rssprobe.sh`) + +`scripts/rssprobe.sh` samples one process's memory every couple of +seconds and appends one CSV row per interval: +`epoch,timestamp,rss_kb,hwm_kb,note`. It reads `/proc//status` +directly — VmRSS plus the peak VmHWM — so every row shows both the +live footprint and the historical high-water mark. That pair is the +diagnosis signal: + +- **RSS at or near HWM** — the process is at its historical peak; + what you are watching is real growth. +- **RSS well below HWM** — memory was freed but not returned to the + OS (allocator retention). The interesting question becomes *what + spiked it earlier*, not *what is growing now*. + +Usage — sampler in one terminal, markers from another while you +work: + +```sh +scripts/rssprobe.sh # sample `pidof hyprlayd` every 2s +scripts/rssprobe.sh -p -i 5 -o d.csv # explicit target/interval/output +scripts/rssprobe.sh mark "VC join" # append a labeled marker row +scripts/rssprobe.sh mark -o d.csv "VC join" # marker into the same CSV +``` + +Ctrl-C stops the sampler. Both commands append to the same CSV +(`rssprobe.csv` in the CWD by default; the header is written once), +so a later plot or diff shows exactly which scenario moved the +needle. + +Runbook for the hyprlayd memory regression (spec: v031 memory, H1–H5): + +1. Cold idle: start the sampler on a freshly restarted daemon, mark + `idle-start`, wait 10 min, mark `idle-end`. +2. H1 bait: mark `window-move-storm`, sweep a window across the + overlay with `dim-on-hover` on (the poll path opens a fresh + Hyprland socket per 50ms tick while connected with a non-empty + roster), then repeat with it off. +3. VC join/leave cycles, camera/stream toggles: mark each; watch RSS + vs HWM after the leave. +4. Repeat the identical series on the v0.3.0 release build (`hyprlayd` + from that tag) and diff the CSVs. + ## Current transport — local IPC (`ipcprobe`) The daemon talks to Discord over Discord's local unix socket at `$XDG_RUNTIME_DIR/discord-ipc-N`; the client side lives in -`src/adapters/ipc.rs`. The wire format is 8-byte little-endian framing -(opcode u32 + payload length u32) carrying JSON payloads. +`src/daemon/adapters/ipc.rs`. The wire format is 8-byte little-endian +framing (opcode u32 + payload length u32) carrying JSON payloads. The probe connects to the socket, does the same framing, sends `HANDSHAKE` then `AUTHORIZE`, and prints every frame for 60s. Use it to @@ -29,7 +74,7 @@ see what a stock Discord client answers and how it frames data. Start here when Discord changes something: confirm at the protocol level whether opcodes or payloads moved before touching -`src/adapters/discord.rs`. +`src/daemon/adapters/discord.rs`. The socket has no HTTP layer, so there is no origin validation — any properly registered application id connects with zero portal diff --git a/scripts/rssprobe.sh b/scripts/rssprobe.sh new file mode 100755 index 0000000..54bedc5 --- /dev/null +++ b/scripts/rssprobe.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# rssprobe.sh — sample one process's memory to CSV for RSS-hunting. +# +# The sampler writes one CSV row per interval: epoch, ISO-8601 timestamp, +# VmRSS, and VmHWM (peak) in kB. RSS below HWM means memory was freed but +# not returned to the OS (allocator retention); RSS climbing toward or +# past the old HWM means real growth. Scenario markers are rows whose +# note column carries the label, so a plot or a diff shows exactly which +# scenario moved the needle. +# +# Usage: +# scripts/rssprobe.sh # sample `pidof hyprlayd` every 2s +# scripts/rssprobe.sh -p PID -i 2 -o f.csv +# scripts/rssprobe.sh mark "VC join" # append a marker row (other shell) +# scripts/rssprobe.sh mark # marker with no label ("scenario") +# +# Ctrl-C stops the sampler. Both commands append to the same CSV (header +# written once), so run the sampler in one terminal and mark scenarios +# from another while you work. + +set -euo pipefail + +usage() { + grep '^# ' "$0" | sed 's/^# //' + exit "${1:-0}" +} + +csv=rssprobe.csv +pid="" +interval=2 + +cmd=${1:-sample} +case $cmd in + mark) + shift + label="" + while [ $# -gt 0 ]; do + case $1 in + -o) csv=$2; shift 2 ;; + -i) shift 2 ;; # tolerated for copy-paste symmetry, unused here + *) label="$label${label:+ }$1"; shift ;; + esac + done + [ -n "$label" ] || label=scenario + [ -f "$csv" ] || echo "epoch,timestamp,rss_kb,hwm_kb,note" > "$csv" + printf '%s,%s,,,%s\n' "$(date +%s)" "$(date +%FT%T)" "$label" >> "$csv" + exit 0 + ;; + sample) + shift || true + ;; + -p | -i | -o | --help) + ;; + *) + usage 2 + ;; +esac + +while [ $# -gt 0 ]; do + case $1 in + -p) pid=$2; shift 2 ;; + -i) interval=$2; shift 2 ;; + -o) csv=$2; shift 2 ;; + --help) usage ;; + *) usage 2 ;; + esac +done + +if [ -z "$pid" ]; then + pid=$(pidof hyprlayd 2>/dev/null | tr ' ' '\n' | head -1) || { + echo "rssprobe: no hyprlayd running; pass -p PID" >&2 + exit 1 + } +fi +if [ "$(pidof hyprlayd 2>/dev/null | wc -w)" -gt 1 ] && [ "$pid" = "$(pidof hyprlayd | tr ' ' '\n' | head -1)" ]; then + echo "rssprobe: multiple hyprlayd processes; sampling $pid (first). Pass -p to pick." >&2 +fi + +status=/proc/$pid/status +[ -r "$status" ] || { echo "rssprobe: cannot read $status" >&2; exit 1; } + +[ -f "$csv" ] || echo "epoch,timestamp,rss_kb,hwm_kb,note" > "$csv" +echo "rssprobe: sampling pid $pid every ${interval}s -> $csv (Ctrl-C to stop)" >&2 + +while :; do + rss=$(awk '/^VmRSS:/{print $2}' "$status") || exit 0 # process gone + hwm=$(awk '/^VmHWM:/{print $2}' "$status") + printf '%s,%s,%s,%s,\n' "$(date +%s)" "$(date +%FT%T)" "$rss" "$hwm" >> "$csv" + sleep "$interval" +done From 7e47616221867dc424a7ded1188b1458f5a90a22 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Sat, 5 Sep 2026 03:16:46 +0700 Subject: [PATCH 08/12] chore: drop the stale tomo demo target from dev.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_demo exec'd target/release/tomo --config-path /tmp/tomo-demo — a binary and flag from the project-ops template this script was copied from. No tomo exists here, and scripts/demo.tape never did, so the target could not run. hyprlay has no tape-driven demo to repoint it at (the surface is a Wayland overlay, not a terminal session), so remove the command — function, description, and help/usage mentions — instead of shipping a dead target. --- dev.sh | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/dev.sh b/dev.sh index 7347086..62a1a62 100755 --- a/dev.sh +++ b/dev.sh @@ -2,7 +2,7 @@ # Development helper script # Usage: ./dev.sh [command1] [command2] ... -# commands: format | lint | test | docs | demo | all | help +# commands: format | lint | test | docs | all | help # plus any commands provided by modules (scripts/dev-*.sh, dev/*.sh, dev-*.sh) # Multiple commands can be specified and will execute left to right @@ -101,33 +101,6 @@ cmd_docs() { } dev_desc docs "Compile Mermaid diagrams to images" -cmd_demo() { - inf "Building release binary..." - cargo build --release - scs "Release build completed" - - inf "Creating wrapper script..." - local wrapper_dir="/tmp/tomo-demo-bin" - mkdir -p "$wrapper_dir" - cat > "$wrapper_dir/tomo" << SCRIPT -#!/bin/bash -exec $PWD/target/release/tomo --config-path /tmp/tomo-demo "\$@" -SCRIPT - chmod +x "$wrapper_dir/tomo" - export PATH="$wrapper_dir:$PATH" - trap "rm -rf $wrapper_dir" EXIT - scs "Wrapper created at $wrapper_dir/tomo" - - if ! command -v vhs &> /dev/null; then - wrn "vhs not found. Install it: https://github.com/charmbracelet/vhs" - fi - - inf "Running demo tape..." - vhs scripts/demo.tape - scs "Demo tape completed" -} -dev_desc demo "Build release, alias, and run vhs demo tape" - cmd_all() { inf "Running all tasks..." cmd_format @@ -180,7 +153,6 @@ Examples: ./dev.sh lint # Run linter ./dev.sh test # Run tests ./dev.sh docs # Compile Mermaid diagrams - ./dev.sh demo # Build release, alias, and run demo tape ./dev.sh format lint # Format then lint ./dev.sh all # Run format, lint, and test From 586f5a501a41193dbc446ed851106236a1a32ae1 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 11 Sep 2026 15:42:54 +0700 Subject: [PATCH 09/12] fix: pin winit-core so lockless installs compile cargo install without --locked re-resolves and ignores Cargo.lock. winit-core 0.31.0-beta.3 added NativeKeyCode::Android, which iced_exdevtools 0.19.1 (unconditional dep of iced_layershell 0.19.1, pinned to the 0.19 line) does not match: E0004. Pin winit-core exactly so every resolution path lands on beta.2, and document --locked in the README install line. Drop the pin once iced_layershell releases on iced_exdevtools 0.20. --- Cargo.lock | 1 + Cargo.toml | 5 +++++ README.md | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f9d9afc..24232a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,6 +1670,7 @@ dependencies = [ "tray-icon", "ureq", "windows-sys 0.59.0", + "winit-core", "winresource", "x11rb", ] diff --git a/Cargo.toml b/Cargo.toml index 1713fed..18dd3ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ iced = { version = "0.14", default-features = false, features = [ ] } iced_layershell = "0.19.1" iced_runtime = "0.14" +# pinned: iced_exdevtools 0.19.1 (unconditional dep of iced_layershell 0.19.1) +# fails to compile against winit-core 0.31.0-beta.3; remove once iced_layershell +# ships a release on the fixed iced_exdevtools 0.20 line +winit-core = "=0.31.0-beta.2" tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time", "macros"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-channel = { version = "0.3", default-features = false, features = ["std"] } @@ -64,6 +68,7 @@ hyprlay-core = { workspace = true } clap = { workspace = true } iced = { workspace = true } iced_runtime = { workspace = true } +winit-core = { workspace = true } tokio = { workspace = true } futures-util = { workspace = true } futures-channel = { workspace = true } diff --git a/README.md b/README.md index 54b01b1..c0c62a5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Download the latest binaries from [releases page](https://github.com/FAZuH/hyprl Or install with [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html): ```sh -cargo install --git https://github.com/FAZuH/hyprlay +cargo install --locked --git https://github.com/FAZuH/hyprlay # Or build from source: cargo build --release From f65be15dddd5b8866591117e6f92e6bcf59cfe53 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 11 Sep 2026 19:41:53 +0700 Subject: [PATCH 10/12] docs: note the lockless-install fix in the changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c711f5..2cec237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Platforms + +- Fixed source installs failing on a fresh dependency resolution + ## 0.3.0 (2026-09-03) ### Platforms From f8a0ab63d15b4101e87e0400d0de8f9d465f6610 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 11 Sep 2026 20:20:46 +0700 Subject: [PATCH 11/12] feat: regenerate all brand assets from the circular-H icon --- CHANGELOG.md | 1 + assets/hyprlay-128.png | Bin 2388 -> 4923 bytes assets/hyprlay-256.png | Bin 4668 -> 9859 bytes assets/hyprlay-48.png | Bin 1213 -> 1894 bytes assets/hyprlay-64.png | Bin 1317 -> 2395 bytes assets/hyprlay.ico | Bin 370070 -> 25524 bytes assets/hyprlay.svg | 23 ++----- assets/tray-connected.png | Bin 124 -> 1894 bytes assets/tray-disconnected.png | Bin 129 -> 1895 bytes scripts/gen_tray_icons.py | 124 ++++++++++++++++++----------------- src/platform/icon.rs | 3 +- 11 files changed, 71 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cec237..a0e9e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Platforms +- Changed the app and tray icon to a circular design - Fixed source installs failing on a fresh dependency resolution ## 0.3.0 (2026-09-03) diff --git a/assets/hyprlay-128.png b/assets/hyprlay-128.png index e75fcb0ea8c3d4d9ea672e3fa3d8e05dc51bbdca..898f8ca4735ab0d38961d2916007eb0c51113867 100644 GIT binary patch literal 4923 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 RTS`U@z|Gmism>vU@i(wsG86y+ diff --git a/assets/hyprlay-256.png b/assets/hyprlay-256.png index 80b63503178cc9ecaa6973b920bc083c453d6f09..a4b2f2057b7bb86f9778d0a205ed28be36ef9baf 100644 GIT binary patch literal 9859 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 literal 4668 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 literal 1213 zcmeAS@N?(olHy`uVBq!ia0vp^1|TfJ0wmYYe(MjU6p}rHd>I(3)EF2VS{N990fib~ zFffz?6~0PfV6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4O;1O92q=Bk{_(QhSc_4$m z#M9T6{S_w@C#ybd>M32IkZOr*L`h0wNvc(HQ7VvPFfuSQ(>1WvH82V>u(UETwK6f# zHZZU4Q212c|9~K0#Ixf5*(W%P1eaq^Fp5I6P9+Q@R;kjBHbbmoe$&xc-C#`iIP0s#~i~2nKN^!(1 zYn{;T=U>dZ<`Ym;>7^wzDd={ZYO#`m!RuKouLdRV4B7W1U)blr+z!oDdv2Cem+PO4 zqa3`y#bzcH9AlmoA~$W5{O=`84FzPb)#Tn>9CY*}Z^->gT(`vxW!}ESH?F^l+Bu(PJxgrn301}E?IpY~@-Imm%_%v1L zHb@#pAF#Y_bMB|4k-X#eDgTeldgyF_S9@BK<%Lwn^)rQktruK#N;v!6@A=KtqP+#o zg$EZkuGpCIK+>+gD!n*r@8yEqt=npk>HZWkThr3HzA=eoC5x;!uX!2UJ(;G1`i=Sx z-ik|(A5j1BY2yw_iS{eG5&Q+KS^wn5n4Yi?(0q z%3`<9ec!mX<$H|)JBNJIM5zvrZnj0ofDZlm-}J#A=5tpa7AYUtB*rxT=be`};l6s& z5&KW5g&#N(_JD84;SV2FH16B@RUWP_;D7bnx9_Crfr!sHHcSb7Z5VfQ&GRRdg7oy4 zW()n*J@QRDPcJ`d_a(LIYxy>tWfk8)6n05!?PfEp>l*3II)9GFzJJ0b(tq&{Z{y^b zQ{?{1Zk4i5sXBPq@QHk>i*CByhpqbxx$ijdcgPYp5Zt<|GJz>*{lSC955CQ7w_X2B zeVW_aHLO3RzVG4RyPUyuRf*Tl{69NOJ$cF$XXlpGYlp^hzPaLkssHtoocnjyo{89! zu<~GGW`a#bmVm~$6(!!SpM$dAuVE?F_;y1_^qc3@C!f1)o1U7*d#1;%+!24OMx)Z+ u_4?;~sllHve_nm%dvi!_e%{Wr!CRkBNmgU}G7p%S89ZJ6T-G@yGywqnRTOyu diff --git a/assets/hyprlay-64.png b/assets/hyprlay-64.png index 6781deee9528857e4f6b8e0cf21f27a024521f2b..59423d28bfdbd34e66cbdb826ecd72566ff6aa16 100644 GIT binary patch literal 2395 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 N002ovPDHLkV1m_7dVv4{ literal 1317 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 diff --git a/assets/hyprlay.svg b/assets/hyprlay.svg index 80b6fbc..ccae025 100644 --- a/assets/hyprlay.svg +++ b/assets/hyprlay.svg @@ -1,19 +1,6 @@ - - - - - + + + + + diff --git a/assets/tray-connected.png b/assets/tray-connected.png index 0696efddc4055d201b4e86070b8aa8dd0531ecc7..3112ea2b915e1cccfc2e733289eaf7c3145b2d33 100644 GIT binary patch delta 1878 zcmV-c2dVgc<_?e~e+FVpM??Vs0RI60puMM)000LYNkl-#dN4ml`lO1q10%f3oB|Gw04Z_s+~YbLI*a zqOGm%fZOf2$Yk=x}^@51ZX|10qr5%hF({qU)hduM@PqD5&0DO zIq(7C+N5X>7!i>hwryXJ$K%hos>7BHh(@FBTI)-|SwI!mX{|}8(`2(*EXxAGFbv#o zH$I;ak#$|#f52_6_3z{H__sT1Kzn=pQNu9)0-PySIGIc`J3GtV+#CxF3uvv2cP2u0 zbv1s!pFki$AP`t<&^IFTt5_`dw9*DB(=>krd;xd?GMNn1)6>k%%#g`sHrwASJRT3V zwY7x9Vcc%FQ=A6A=f8?LKWm!y2OfWq?jcwaoQhY5+ zDZ=3}4Gj$_wK5{V$z(F0Jb3V6wv=p11M-vfF3<{)N~K675-cq(RYdW%_7 zsd0*a5RtR7SZrz|nGFrdD?bPv0hpYeWPE&l!|`1yM1;o1Mh+i7>=aFiNc%=c7{v;^ zy1L%ce_G!M-US#P9p%}xEw6O{Gs$FP4kk7{1TRj&E(|djw)J;*RNk= z7=|+^nj?|O;>5(nP+^-@bD*Q6qgh1$3Havbf981f=+Tak`ErN|$B!R(j99WP>;1vO z!6#0=TGRNifX}il5{bl~D-X+#GLc9)OSacAj6W6Due^bLcrXAkHa1pv==AjT6gnRO z5{U$fM53fzRaF&d&YW3SK0G|kix)38lxt{cAQFizD=l&=7K=Sx)xb2(Tc&C1Q>RX8 zf2EXW*$Zy?OQlkJEmy8wDJvI=MApe^t@Ziy=S#{NhM`ZNKCMmD)TU|PaP(FH?d|PH zSPsl8>R~I1Wm!y3t+;}o&d$zy$Qxi7#wUQ%S~D{I0!K#?`KYa}?SS%lJRg$ZNzKj8 z?WJy;!g&LL%jI%?pe)O3f#vmfVPRqKG}|PLi;FA%Nkoph$Omlcbb8y4>*UFkT)cR( zWSe||wq{ z50J~{cD&JIBof({0ZJ*nUM~P5GOD!J ziTnlC)zu}1-v+9xs#cO=TIZ((|DlcT3=564owrw*qGD13?F00UP;Pd%7apHtCGH&+w z_n&j>R*e^dSsOG}HR{2R-%R>kY9Qj23_W64M)k_CR^^?FfC zkxV9cQQh@4H8pYQ&>;Y{f7X`<1_o{y*00NrPE1UE8;L}I1RSfWsll==UcGv?JKC-x z6bjMU*yvo|j>qGluWeIoxzUzoeFpph%TJ(S_U{_uaG2)iX6N#I5$UDKkyFw0fyv3q zbYo-V4d54mKNt*R7)IfSddCnEnwpwc(ulygVHjO~eSQBf+P>7yf1=T7SZjR;cn_Ad z7Kub+$5y9(`}Wb&(!#-m2c4qtbGh8vp`oD-4=9Sh0f+JN@mHZx=qtl8-Upg}J|Ce_ z2oYg^e!gPaYr`-&a^wiDt*y>p7vQFdoEsPzDEj0bHoH!WX_~(Veh2sfve_(CQ&ZbM zX05BMqrSer&?&yre_CIT$K!vb6348CM5EDnwbp+Ie&&>Etx2U)%+Ah|N~Ks_T--1@ ztdydvstUi~PcRt7@At3uH8(BGx;QvE_^-|CwAD`yPHL@x3-nOrQ-h_YB{G@Js!t6( z9uHoxx0rLZfLltbt9^ZarQ?Z88Q{>_*;#K{R#~ zT8@gv3ejkEzt;MMh#a$R`XR^FT^Oo@lKfd%fQ8?%lgrkv!&q0Baxh!@r0V Q=>Px#07*qoM6N<$g8GEEzW@LL delta 94 zcmaFHS2IB+!r9ZsF{I+w+p7mT85}s69e?LXdG9#i*|C|G<=IpY&q-Sr)R^pLX8?f$ q=?~T*x*?Xqp9jQXxWG9<#gkVwXleOR$47s{K|-FcelF{r5}E)~P9KT@ diff --git a/assets/tray-disconnected.png b/assets/tray-disconnected.png index c0df9eceddee085b0601de1282e3bfb5e2822e4c..e331041e2fc86468d341ad184a30802138e43d6e 100644 GIT binary patch delta 1880 zcmV-e2dDUf0p|{oB!32COGiWi{{a60|De66lK=n*97#k$RA_=RW*{ z9|6Ne5f~>JF(jY~gQ`wuT7j?yYMORhHeF^abya0E(QK+}DpjLy(nY0KrCsC?Xr{Ad z5>2LM5sZOJGpQn_Xe1{j{E7)km9QZ|%(LISUEqfd7{h@1(SPsC@7#Od{hs&Ez4yHD zwNNBacYu;Tx@LffB68cd?OV}k^u>NT>??q9INYhV{usCdsN6QKHK|mJbUKY?SpXP@ zfy?E>>-8eCZGT7`xU04PeKZ>V>QDpd?Cd;m7{*_K%ejOTi3AG^3oI@!va+&*);j-S zB9xbx>5B%PtO~fOn>H6vQOQzET*TYnVp@*w(Wfp z-wLG^fk1$Uh6a?{$jC2E)BNE6{rd}rdJ^4zICG*o7!0mXPfw5L_SrNCy1KfWMdUw#cYkqlk;ji8A9~L31`(mXz1<07&9bbw zM@B}TI_+vp=f478%d&{YV#iJ%);G#nEaoiP9>XyHl-s`X2D0VBFu>&G+9puqenaX#p7}6>+5m3TxhL- z1N1^Rg*rPs&#_*Z6?KO#CM?U!eX#F$cX!uAHUPsgJ^+-~n)&(pW1_e_=I7^~d8sVR zx(e9TKVYpqGA(+9dSKwDcIZEbA_zRzwjO@9-w z*9%asY}>8?$Ye5yK5((%hy}o@eC7226_iqH8K9(OuaeI3Bc}#?RbtzALPT;^fqi3a zY>ct7v4VXnDk|vh?LE*qZnxX{nJ5vFabQDqI><`&{P}YR1_lcDIeq%{fdWuU;qiC? z5Rq}EwT@*kC@(KB=zJ1TR#vtlhJR_TpD4?+9s+3~SI#)GsIIPdex^m_p~|Yn{{|ct z;6x)QfCrICWLW`-$Ze-FXM&tKxZQ4iJ|6%g@;PM7Y1_7M0k()xTU&d440l0YU7eHv zmQw2PkPRTK&))?I1OmDGLePUvwpkJPFGc+``v5{=b6~iz-1X4;VT3cK5ou(W)M1+=>7Ki+6%d$SoZQqmwlarH)U@({l ze&F$VP)d{wf#@eh0WvRaJ#$Su8Cr z9Sz$p@caEVHa0q!ccanhhg@}DZ?;&1_uZKn}4@|p__%n;egiq zAHZ9%u3E%mu|r#(N=r*=X=$OdveGe)Wipv7qoboc9#G_c0}iI9rk4DE|7V6_ybUya zy~)=#P$={(;CFx*Ae~MV zkH-&u%vx7hw;|eQ@qb!t{o80X`bUa!%$kdEIQ*v8`p>|R9h=sgWHQOZ!UD-;lGWAK z9mQd#6lG;)_2Ik3nEelI5ONakQ9-pTI(kskLT-wfq|mvnEwMW&cP~L SQW%c_0000 vJ~IOd{E#lF1~WD@*cpNu4(bk*R7|%p&T5yk{@Iio2NLvj^>bP0l+XkKT09}m diff --git a/scripts/gen_tray_icons.py b/scripts/gen_tray_icons.py index c157cbd..f15422c 100644 --- a/scripts/gen_tray_icons.py +++ b/scripts/gen_tray_icons.py @@ -1,62 +1,64 @@ #!/usr/bin/env python3 -"""Generate the two tray icons (RGBA PNG, 48x48) with an "H" glyph. -Run once; the PNGs are committed under assets/. No third-party deps.""" -import zlib, struct, os - -W = H = 48 - -def draw_h(color): - """Return a W*H*4 RGBA bytearray with an "H" glyph on a transparent - background. `color` is (r, g, b, a).""" - r, g, b, a = color - bar = 8 # stroke thickness of each bar - margin = 10 # gap from the canvas edge to the vertical bars - # Vertical bars span the full letter height. - v_top, v_bot = margin, H - margin - # Horizontal center bar, vertically centered. - c_top = H // 2 - bar // 2 - c_bot = H // 2 + bar // 2 - left = margin - right = W - margin - bar - buf = bytearray(W * H * 4) - for y in range(H): - for x in range(W): - on = False - if v_top <= y < v_bot: - if left <= x < left + bar: - on = True - if right <= x < right + bar: - on = True - if c_top <= y < c_bot and left + bar <= x < right: - on = True - i = (y * W + x) * 4 - if on: - buf[i:i+4] = bytes((r, g, b, a)) - # else: leave transparent (alpha 0) - return buf - -def write_png(path, pixels): - raw = bytearray() - for y in range(H): - raw.append(0) # filter type 0 (none) - raw += pixels[y * W * 4:(y + 1) * W * 4] - comp = zlib.compress(bytes(raw), 9) - def chunk(typ, data): - body = typ + data - return (struct.pack(">I", len(data)) + body - + struct.pack(">I", zlib.crc32(body) & 0xffffffff)) - sig = b'\x89PNG\r\n\x1a\n' - ihdr = struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0) # 8-bit RGBA - with open(path, "wb") as f: - f.write(sig + chunk(b'IHDR', ihdr) - + chunk(b'IDAT', comp) + chunk(b'IEND', b'')) - -here = os.path.dirname(os.path.abspath(__file__)) -assets = os.path.join(os.path.dirname(here), "assets") -os.makedirs(assets, exist_ok=True) -write_png(os.path.join(assets, "tray-connected.png"), - draw_h((255, 255, 255, 255))) # white H -write_png(os.path.join(assets, "tray-disconnected.png"), - draw_h((160, 160, 160, 255))) # dim grey H -print("wrote", os.path.join(assets, "tray-connected.png"), - os.path.join(assets, "tray-disconnected.png")) +"""Regenerate every brand asset in assets/ from assets/hyprlay.svg. + +That SVG is the single source for all sizes; outputs are committed, so +run this after editing it and commit the result. +""" +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ASSETS = os.path.join(os.path.dirname(HERE), "assets") +SVG = os.path.join(ASSETS, "hyprlay.svg") + +H_FILL = 'fill="#ffffff"' +H_DIMMED = 'fill="#a0a0a0"' +TRAY_PX = 48 +APP_PX = (48, 64, 128, 256) +ICO_SIZES = [(px, px) for px in sorted(APP_PX + (32, 16), reverse=True)] + + +def raster(svg_text, px, out_path): + with tempfile.NamedTemporaryFile("w", suffix=".svg", delete=False) as f: + f.write(svg_text) + src = f.name + try: + subprocess.run( + ["rsvg-convert", "-w", str(px), "-h", str(px), "-o", out_path, src], + check=True, + ) + finally: + os.unlink(src) + + +def main(): + if not shutil.which("rsvg-convert"): + sys.exit("error: rsvg-convert not found (install librsvg)") + try: + from PIL import Image + except ImportError: + sys.exit("error: Pillow not found (needed for assets/hyprlay.ico)") + + with open(SVG) as f: + svg = f.read() + if svg.count(H_FILL) != 1: + sys.exit(f"error: expected exactly one {H_FILL} (the H glyph) in {SVG}") + + raster(svg, TRAY_PX, os.path.join(ASSETS, "tray-connected.png")) + raster(svg.replace(H_FILL, H_DIMMED, 1), TRAY_PX, + os.path.join(ASSETS, "tray-disconnected.png")) + for px in APP_PX: + raster(svg, px, os.path.join(ASSETS, f"hyprlay-{px}.png")) + + master = os.path.join(ASSETS, "hyprlay-256.png") + Image.open(master).save( + os.path.join(ASSETS, "hyprlay.ico"), format="ICO", sizes=ICO_SIZES + ) + print("wrote tray pair, hyprlay PNGs", APP_PX, "and hyprlay.ico from", SVG) + + +if __name__ == "__main__": + main() diff --git a/src/platform/icon.rs b/src/platform/icon.rs index 5379610..f9c449f 100644 --- a/src/platform/icon.rs +++ b/src/platform/icon.rs @@ -1,4 +1,5 @@ -//! Bundled app icon: the brand mark (black rounded square, bold white "H") +//! Bundled app icon: the brand mark (black disc with a dark-grey ring, +//! bold white "H") //! as a scalable SVG plus the PNG/ICO raster sizes, all committed under //! `assets/hyprlay.{svg,png,ico}`. This module exposes the iced window icon //! built from the embedded 256px PNG (used by the settings GUI and the From c2aee7bd5e36f9164e6848a1ecb750ed089685c9 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 11 Sep 2026 20:51:30 +0700 Subject: [PATCH 12/12] chore: add gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/