From d3d9418f6e4dd81e36287b5407d7a16926e4b8f8 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 13:03:36 +0200 Subject: [PATCH 1/5] feat: program categories, media/Discord LED sources, output resync, tray fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Program categories for process actions (src/app_groups.rs). A slider or button can target "Browsers" or "Games" instead of one named executable, so a newly installed game or browser needs no config change. Membership comes from the bundled assets/app_groups.json, from path heuristics (anything under a Steam/Epic/GOG library is a game — the part that keeps the list from going stale), and from the OS itself (browsers registered under StartMenuInternet). Users can extend any group with an app_groups.json in the config directory. Stored as "@group:"; WASAPI resolves membership per audio session, using the process image path as well as its name. Re-apply volumes after the default output device changes (src/actuator.rs). Per-app volumes live on the audio session, and a session that follows the default device to a new endpoint arrives carrying that endpoint's remembered level — so the slider and the actual volume drift apart until the next nudge, which is the jump this fixes. The actuator now remembers the last volume set per target, polls the default endpoint id (new AudioBackend::default_output_id) and replays twice after a switch, for the streams that move late. Mute state is deliberately untouched. Two new LED condition sources: the OS media session (src/media.rs, WinRT SMTC — covers Spotify, browser tabs, any player publishing transport controls) and Discord voice state (src/discord.rs, local RPC socket: self-mute, deafen, in a voice channel). Discord needs a one-off OAuth application; its client secret is only read until a token is cached, and SLIDR_DISCORD_CLIENT_SECRET can supply it instead of the settings file. Tray: after an autostart --hidden launch the menu read "Minimize Slidr" while clicking it opened the window. The label is now seeded from the startup state and re-derived from the real window state on every poll, so it cannot drift. Double-click (and the menu entry) also restore a window minimized to the taskbar now, raising it to the foreground. Also adds .github/workflows/ci.yml, authored on the Linux-fix branch and included here so this branch gets the same Linux verification. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 6 + README.md | 19 ++ assets/app_groups.json | 391 +++++++++++++++++++++++++++ src/actuator.rs | 86 +++++- src/app_groups.rs | 411 +++++++++++++++++++++++++++++ src/audio/mod.rs | 12 + src/audio/wasapi.rs | 41 ++- src/discord.rs | 582 +++++++++++++++++++++++++++++++++++++++++ src/glue.rs | 174 ++++++++++-- src/led.rs | 10 +- src/main.rs | 9 +- src/media.rs | 145 ++++++++++ src/model.rs | 29 +- src/tray.rs | 84 +++++- ui/app.slint | 117 ++++++++- 15 files changed, 2071 insertions(+), 45 deletions(-) create mode 100644 assets/app_groups.json create mode 100644 src/app_groups.rs create mode 100644 src/discord.rs create mode 100644 src/media.rs diff --git a/Cargo.toml b/Cargo.toml index 18b220e..289e045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,12 @@ windows = { version = "0.58", features = [ "Win32_System_ProcessStatus", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", + # Restoring/raising our own window from the tray (EnumWindows + SetForegroundWindow). + "Win32_UI_WindowsAndMessaging", + # OS media session (SMTC) as an LED state source — "music is playing". + "Media_Control", + "Foundation", + "Foundation_Collections", ] } tray-icon = "0.19" image = { version = "0.25", default-features = false, features = ["ico"] } diff --git a/README.md b/README.md index f58b0ab..d3025a4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ and macro board. physical sliders; volume mapped through a configurable response curve. - **Button actions** — mute process / main audio / mic, open an application, open a website, or simulate a key (full key library ported from the original). +- **Program categories** — target *Browsers* or *Games* instead of one + executable, so a newly installed game or browser is picked up without editing + anything. Membership comes from `assets/app_groups.json`, from path heuristics + (anything under a Steam/Epic/GOG library is a game), and from the OS itself + (browsers registered with Windows). Extend or add categories with an + `app_groups.json` in the config directory. - **Profiles** — each profile carries its own categories, slider/button assignments, **and** appearance + slider settings (theme, accent, dead-zone, curve, unmute-on-change). Create / rename / import / export / switch. @@ -25,6 +31,15 @@ and macro board. presets are visualised read-only. - **System integration** — minimise-to-tray (Windows), autostart, start minimised, light/dark/system theme. +- **Output-switch resync** — when the default output device changes, the volumes + Slidr last applied are re-sent, so the sliders and the actual levels don't + drift apart (per-app volumes otherwise follow the *new* endpoint's remembered + levels). +- **LED state sources** (experimental) — besides mute/volume/HTTP, an LED can + follow the OS media session ("music is playing", any player that publishes + transport controls) or Discord's voice state (self-mute / deafen / in a voice + channel). Discord needs a one-off OAuth application — see + [`src/discord.rs`](src/discord.rs) for the three setup steps. - **Hot-plug** — automatic Arduino detection, reconnect with backoff. ## Architecture @@ -52,6 +67,10 @@ src/ ├── curve.rs Slider normalization + cubic Bézier curves [tests] ├── keys.rs enigo wrapper with hold/repeat semantics ├── keys_library.rs Virtual-key catalogue (media, F-keys, …) +├── app_groups.rs Curated program categories + OS discovery [tests] +├── media.rs OS media session ("is music playing") [tests] +├── discord.rs Discord RPC voice state (local socket) [tests] +├── led.rs LED engine: conditions → board commands ├── storage.rs JSON: global settings + per-profile folders ├── autostart.rs XDG desktop entry / HKCU\Run ├── tray.rs Windows system tray (cfg-gated) diff --git a/assets/app_groups.json b/assets/app_groups.json new file mode 100644 index 0000000..290ef89 --- /dev/null +++ b/assets/app_groups.json @@ -0,0 +1,391 @@ +{ + "version": 1, + "_comment": [ + "Curated process groups for Slidr's program actions. A group matches a running", + "program when its executable name contains one of `match` (case-insensitive,", + "with a trailing .exe stripped), or its full path contains one of `paths`,", + "unless the name contains one of `exclude`.", + "", + "`paths` is what keeps the list from going stale: anything under a game", + "library folder counts as a game without being named here. Slidr additionally", + "discovers installed browsers from the OS at runtime (see src/app_groups.rs),", + "so a newly installed browser is picked up without editing this file.", + "", + "Users can extend every group — or add their own — via app_groups.json in the", + "Slidr config directory; that file is merged on top of this one." + ], + "groups": [ + { + "id": "browsers", + "name": "Browsers", + "icon": 3, + "match": [ + "chrome", + "chromium", + "thorium", + "msedge", + "iexplore", + "firefox", + "librewolf", + "waterfox", + "floorp", + "zen", + "mullvad-browser", + "brave", + "opera", + "vivaldi", + "safari", + "yandex", + "ungoogled", + "midori", + "epiphany", + "falkon", + "qutebrowser", + "maxthon", + "whale", + "ghostbrowser", + "tor browser", + "torbrowser", + "arc.exe", + "maxthon" + ], + "exclude": [ + "crashpad", + "chromedriver", + "geckodriver", + "updater", + "installer", + "webview", + "msedgewebview" + ] + }, + { + "id": "games", + "name": "Games", + "icon": 6, + "paths": [ + "\\steamapps\\common\\", + "/steamapps/common/", + "\\epic games\\", + "/epic games/", + "\\gog galaxy\\games\\", + "/gog galaxy/games/", + "\\gog games\\", + "\\riot games\\", + "/riot games/", + "\\battle.net\\", + "\\blizzard\\", + "\\call of duty", + "\\ea games\\", + "\\origin games\\", + "\\electronic arts\\", + "\\ubisoft\\", + "\\amazon games\\", + "\\rockstar games\\", + "\\bethesda.net\\", + "\\xboxgames\\", + "\\modifiablewindowsapps\\", + "\\.minecraft\\", + "/.minecraft/", + "\\my games\\", + "\\games\\" + ], + "match": [ + "javaw", + "minecraft", + "factorio", + "terraria", + "stardew", + "rocketleague", + "valorant", + "leagueoflegends", + "league of legends", + "dota2", + "csgo", + "gta5", + "gtav", + "rdr2", + "eldenring", + "cyberpunk2077", + "helldivers", + "starfield", + "fortniteclient", + "overwatch", + "destiny2", + "warframe", + "r5apex", + "hogwartslegacy", + "baldursgate3", + "palworld", + "satisfactory", + "deadlock", + "marvelrivals", + "thefinals", + "delta force", + "escapefromtarkov", + "rust_client", + "dayz", + "arma", + "warthunder", + "worldoftanks", + "worldofwarcraft", + "ffxiv", + "genshinimpact", + "starrail", + "zenlesszonezero", + "roblox", + "amongus", + "phasmophobia", + "lethalcompany", + "content warning", + "r5apex", + "cs2.exe", + "bg3_dx11", + "apex legends" + ], + "exclude": [ + "steam", + "steamwebhelper", + "epicgameslauncher", + "epicwebhelper", + "galaxyclient", + "riotclientservices", + "riotclientux", + "battle.net", + "blizzardbrowser", + "eadesktop", + "eabackgroundservice", + "origin", + "ubisoftconnect", + "upc", + "uplay", + "gameoverlayui", + "launcher", + "crashhandler", + "crashreporter", + "anticheat", + "easyanticheat", + "battleye", + "vanguard" + ] + }, + { + "id": "music", + "name": "Music players", + "icon": 5, + "match": [ + "spotify", + "spotifyd", + "tidal", + "deezer", + "qobuz", + "youtubemusic", + "applemusic", + "itunes", + "amazonmusic", + "soundcloud", + "pandora", + "foobar2000", + "musicbee", + "aimp", + "winamp", + "clementine", + "strawberry", + "audacious", + "rhythmbox", + "amberol", + "lollypop", + "elisa", + "quodlibet", + "cmus", + "mpd", + "ncspot", + "nuclear", + "feishin", + "supersonic", + "navidrome" + ], + "exclude": [ + "helper", + "webhelper", + "crashpad" + ] + }, + { + "id": "comms", + "name": "Voice & chat", + "icon": 1, + "match": [ + "discord", + "discordptb", + "discordcanary", + "vesktop", + "armcord", + "webcord", + "teamspeak", + "ts3client", + "mumble", + "ventrilo", + "slack", + "teams", + "ms-teams", + "zoom", + "skype", + "telegram", + "whatsapp", + "signal", + "element", + "revolt", + "guilded", + "matrix", + "wire", + "viber", + "rocketchat", + "mattermost" + ], + "exclude": [ + "crashpad", + "updater", + "installer", + "helper" + ] + }, + { + "id": "video", + "name": "Video & streaming", + "icon": 6, + "match": [ + "vlc", + "mpv", + "mpc-hc", + "mpc-be", + "potplayer", + "kmplayer", + "smplayer", + "netflix", + "primevideo", + "disneyplus", + "hulu", + "crunchyroll", + "plex", + "plexamp", + "jellyfin", + "jellyfinmediaplayer", + "kodi", + "emby", + "twitch", + "streamlabs", + "obs64", + "obs32", + "obs", + "xsplit", + "moonlight", + "parsec", + "sunshine", + "stremio", + "wmplayer", + "totem", + "celluloid", + "haruna" + ], + "exclude": [ + "crashpad", + "helper", + "updater" + ] + }, + { + "id": "creative", + "name": "Creative tools", + "icon": 6, + "match": [ + "photoshop", + "illustrator", + "premiere", + "afterfx", + "audition", + "animate", + "lightroom", + "indesign", + "resolve", + "davinci", + "fusion", + "blender", + "unity", + "unityhub", + "unrealeditor", + "ue4editor", + "ue5editor", + "godot", + "audacity", + "reaper", + "ableton", + "flstudio", + "cubase", + "studio one", + "bitwig", + "cakewalk", + "renoise", + "lmms", + "ardour", + "mixcraft", + "vegas", + "capcut", + "shotcut", + "kdenlive", + "handbrake", + "gimp", + "krita", + "inkscape", + "affinity", + "figma", + "clipstudiopaint", + "aseprite" + ], + "exclude": [ + "crashhandler", + "crashreporter", + "updater", + "installer" + ] + }, + { + "id": "meeting", + "name": "Meetings", + "icon": 1, + "match": [ + "zoom", + "teams", + "ms-teams", + "webex", + "gotomeeting", + "bluejeans", + "ringcentral", + "chime", + "whereby", + "jitsi" + ], + "exclude": [ + "crashpad", + "updater", + "helper" + ] + }, + { + "id": "system", + "name": "System sounds", + "icon": 2, + "match": [ + "audiodg", + "svchost", + "explorer", + "shellexperiencehost", + "systemsettings", + "searchhost", + "textinputhost", + "applicationframehost", + "narrator" + ] + } + ] +} diff --git a/src/actuator.rs b/src/actuator.rs index 40c454e..a3bb82e 100644 --- a/src/actuator.rs +++ b/src/actuator.rs @@ -3,7 +3,8 @@ //! renderer smooth — per-frame WASAPI session enumeration / `pactl` calls never //! block the event loop. -use std::time::Duration; +use std::collections::HashMap; +use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; @@ -43,6 +44,14 @@ fn run(rx: Receiver, serial: SerialLink, led_state: LedStateHandle) { }; let mut led = LedEngine::new(serial, led_state); + // Last volume applied per target. Re-played after an output-device switch + // (see `OUTPUT_REAPPLY_DELAYS`). + let mut applied: HashMap = HashMap::new(); + let mut last_output = audio.default_output_id(); + let mut next_output_check = Instant::now() + OUTPUT_POLL; + // Scheduled re-apply passes, earliest first. + let mut reapply: Vec = Vec::new(); + // Block for commands but wake regularly so the LED engine can re-evaluate // its conditions (mute/volume polling, cached API results) and push updates. let tick = Duration::from_millis(100); @@ -56,13 +65,54 @@ fn run(rx: Receiver, serial: SerialLink, led_state: LedStateHandle) { batch.push(more); } for cmd in coalesce(batch) { - apply(&*audio, keys.as_mut(), &mut led, cmd); + apply(&*audio, keys.as_mut(), &mut led, &mut applied, cmd); } } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } led.tick(&*audio); + + let now = Instant::now(); + if now >= next_output_check { + next_output_check = now + OUTPUT_POLL; + let cur = audio.default_output_id(); + // `None` means the backend can't report an id — never treat that as + // a switch, or we'd re-apply on every poll. + if cur.is_some() && cur != last_output { + log::info!("default output changed; re-applying volumes"); + last_output = cur; + reapply = OUTPUT_REAPPLY_DELAYS.iter().map(|d| now + *d).collect(); + } + } + if reapply.first().is_some_and(|t| now >= *t) { + reapply.remove(0); + reapply_volumes(&*audio, &applied); + } + } +} + +/// How often the default output endpoint is checked for a switch. +const OUTPUT_POLL: Duration = Duration::from_millis(400); + +/// When to re-apply volumes after the default output changed, measured from the +/// moment we noticed. Sessions don't all migrate to the new endpoint at once — +/// an app can take a beat to reopen its stream — so we replay twice: once for +/// the streams that moved immediately, once for the stragglers. +const OUTPUT_REAPPLY_DELAYS: [Duration; 2] = + [Duration::from_millis(350), Duration::from_millis(1500)]; + +/// Re-send the last volume we set for every target. +/// +/// Why this is needed: per-app volumes live on the audio *session*, and a +/// session that follows the default device to a new endpoint arrives carrying +/// that endpoint's remembered level, not the one the slider is sitting at. The +/// physical slider and the actual volume then disagree until the next nudge — +/// which is the jump the user sees. Mute state is deliberately left alone: the +/// switch shouldn't un-mute anything the user muted on purpose. +fn reapply_volumes(audio: &dyn AudioBackend, applied: &HashMap) { + for (target, value) in applied.values() { + audio.set_volume(vol_target(target), *value); } } @@ -95,7 +145,13 @@ fn target_key(t: &Target) -> String { } } -fn apply(audio: &dyn AudioBackend, keys: Option<&mut KeyController>, led: &mut LedEngine, cmd: Cmd) { +fn apply( + audio: &dyn AudioBackend, + keys: Option<&mut KeyController>, + led: &mut LedEngine, + applied: &mut HashMap, + cmd: Cmd, +) { match cmd { Cmd::SetLeds(cfgs) => led.set_configs(*cfgs), Cmd::SetLedExperimental(on) => led.set_experimental(on), @@ -108,11 +164,31 @@ fn apply(audio: &dyn AudioBackend, keys: Option<&mut KeyController>, led: &mut L audio.set_mute(mute_target(&target), false); } audio.set_volume(vol_target(&target), value); + applied.insert(target_key(&target), (target, value)); } Cmd::ToggleMute(target) => audio.toggle_mute(mute_target(&target)), Cmd::Open(t) => { - if let Err(e) = open::that(&t) { - log::warn!("open {t:?} failed: {e}"); + // A program group resolves to whichever member is running right now + // ("Games" → the game you're in). Nothing running means there is + // nothing to open: a group has no single executable of its own. + let target = match crate::app_groups::parse_ref(&t) { + Some(id) => match crate::app_groups::running_in_group(id) + .into_iter() + .find_map(|a| a.path) + { + Some(path) => path, + None => { + log::info!( + "open: no running program in group {:?}; nothing to launch", + crate::app_groups::display_name(id) + ); + return; + } + }, + None => t, + }; + if let Err(e) = open::that(&target) { + log::warn!("open {target:?} failed: {e}"); } } Cmd::KeyDown(vk) => { diff --git a/src/app_groups.rs b/src/app_groups.rs new file mode 100644 index 0000000..0deaf80 --- /dev/null +++ b/src/app_groups.rs @@ -0,0 +1,411 @@ +//! Curated program categories ("Browsers", "Games", …) for process actions. +//! +//! A slider or button can target a *group* instead of one named executable, so a +//! newly installed browser or a freshly bought game is picked up without editing +//! the config. Membership comes from three places, cheapest first: +//! +//! 1. the bundled list in `assets/app_groups.json` (executable-name stems), +//! 2. path heuristics from the same file — anything under a game library folder +//! is a game, whatever it happens to be called. This is what keeps the +//! "Games" group useful without an impossible list of every game ever made, +//! 3. runtime discovery from the OS (Windows: browsers registered under +//! `StartMenuInternet`), refreshed periodically. +//! +//! Users can extend or add groups with an `app_groups.json` in the Slidr config +//! directory; it is merged on top of the bundled file at startup. + +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +/// Sentinel prefix marking a stored target as a group id rather than a process +/// name. Written into `AudioStream::process` / `ButtonAction::property` by the +/// wizard, parsed back by [`parse_ref`]. +const GROUP_PREFIX: &str = "@group:"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppGroup { + pub id: String, + pub name: String, + /// Picker icon kind (matches the UI's `KindIcon`). + #[serde(default)] + pub icon: i32, + /// Executable-name stems: a program matches when its name equals one of + /// these or starts with it (".exe" stripped, case-insensitive). + #[serde(default, rename = "match")] + pub patterns: Vec, + /// Full-path fragments; a program matches when its image path contains one. + #[serde(default)] + pub paths: Vec, + /// Name stems that veto a match (launchers, crash handlers, helpers). + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct GroupFile { + #[serde(default)] + groups: Vec, +} + +const BUNDLED: &str = include_str!("../assets/app_groups.json"); + +static GROUPS: OnceLock> = OnceLock::new(); + +/// All known groups: the bundled list merged with the user's overrides. +pub fn groups() -> &'static [AppGroup] { + GROUPS.get_or_init(|| { + let mut list: Vec = match serde_json::from_str::(BUNDLED) { + Ok(f) => f.groups, + Err(e) => { + // The bundled file ships inside the binary, so this is a build + // mistake rather than a user problem — but never panic over it. + log::error!("bundled app_groups.json is invalid ({e}); groups disabled"); + Vec::new() + } + }; + for extra in user_groups() { + match list.iter_mut().find(|g| g.id == extra.id) { + // Same id: extend the bundled group rather than replace it, so + // adding one game doesn't throw away the curated list. + Some(g) => { + g.patterns.extend(extra.patterns); + g.paths.extend(extra.paths); + g.exclude.extend(extra.exclude); + if !extra.name.is_empty() { + g.name = extra.name; + } + } + None => list.push(extra), + } + } + list + }) +} + +fn user_groups() -> Vec { + let path = crate::storage::config_root().join("app_groups.json"); + let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new() }; + match serde_json::from_str::(&text) { + Ok(f) => { + log::info!("app groups: merged {} user entries from {path:?}", f.groups.len()); + f.groups + } + Err(e) => { + log::warn!("app groups: {path:?} is invalid ({e}); ignoring it"); + Vec::new() + } + } +} + +pub fn group(id: &str) -> Option<&'static AppGroup> { + groups().iter().find(|g| g.id == id) +} + +/// Display name for a group id (falls back to the raw id). +pub fn display_name(id: &str) -> String { + group(id).map(|g| g.name.clone()).unwrap_or_else(|| id.to_string()) +} + +/// Encode a group id as a stored target string. +pub fn make_ref(id: &str) -> String { + format!("{GROUP_PREFIX}{id}") +} + +/// Decode a stored target: `Some(group_id)` for a group, `None` for a plain +/// process name. +pub fn parse_ref(value: &str) -> Option<&str> { + value.strip_prefix(GROUP_PREFIX) +} + +/// Human-readable label for a stored target (group name, or the value itself). +pub fn label_for(value: &str) -> String { + match parse_ref(value) { + Some(id) => display_name(id), + None => value.to_string(), + } +} + +/// Executable name without directories or a trailing ".exe", lowercased. +pub fn stem(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + let base = base.strip_suffix(".exe").unwrap_or(base); + base.to_lowercase() +} + +/// Does `exe_name` (optionally with its full `exe_path`) belong to `group_id`? +pub fn matches(group_id: &str, exe_name: &str, exe_path: Option<&str>) -> bool { + let Some(g) = group(group_id) else { return false }; + let name = stem(exe_name); + if name.is_empty() { + return false; + } + // A veto beats every rule below: launchers and crash handlers live in the + // same folders as the programs they launch. + if g.exclude.iter().any(|e| name.contains(&e.to_lowercase())) { + return false; + } + // Name stems. Equality-or-prefix rather than `contains`, which would let a + // three-letter stem claim half the process table. + if g.patterns.iter().any(|m| { + let m = stem(m); + !m.is_empty() && (name == m || name.starts_with(&m)) + }) { + return true; + } + // Path heuristics (game libraries and the like). + if let Some(path) = exe_path { + let path = path.to_lowercase(); + if g.paths.iter().any(|p| path.contains(&p.to_lowercase())) { + return true; + } + } + // OS-discovered members (browsers registered with Windows). + discovered(group_id).iter().any(|d| &name == d) +} + +// ── runtime discovery ─────────────────────────────────────────────────────── + +/// How long an OS-discovered member list is reused before re-querying. +const DISCOVERY_TTL: Duration = Duration::from_secs(300); + +static DISCOVERED: OnceLock)>>> = OnceLock::new(); + +/// Executable stems the OS itself reports as belonging to `group_id`. Only +/// "browsers" has a registry to read today; every other group returns empty. +fn discovered(group_id: &str) -> Vec { + if group_id != "browsers" { + return Vec::new(); + } + let cell = DISCOVERED.get_or_init(|| Mutex::new(None)); + let mut slot = cell.lock(); + if let Some((at, list)) = slot.as_ref() { + if at.elapsed() < DISCOVERY_TTL { + return list.clone(); + } + } + let list = discover_browsers(); + log::debug!("app groups: discovered {} registered browser(s)", list.len()); + *slot = Some((Instant::now(), list.clone())); + list +} + +/// Browsers registered with Windows under `Clients\StartMenuInternet` — the same +/// list the "Default apps" page shows, so anything the user installed properly +/// is covered without us knowing its name in advance. +#[cfg(target_os = "windows")] +fn discover_browsers() -> Vec { + use windows::core::{PCWSTR, PWSTR}; + use windows::Win32::System::Registry::{ + RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + KEY_READ, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + let mut out: Vec = Vec::new(); + let subkey = wide("SOFTWARE\\Clients\\StartMenuInternet"); + for root in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + unsafe { + let mut key = HKEY::default(); + if RegOpenKeyExW(root, PCWSTR(subkey.as_ptr()), 0, KEY_READ, &mut key).is_err() { + continue; + } + for index in 0..64u32 { + let mut buf = [0u16; 256]; + let mut len = buf.len() as u32; + let r = RegEnumKeyExW( + key, + index, + PWSTR(buf.as_mut_ptr()), + &mut len, + None, + PWSTR::null(), + None, + None, + ); + if r.is_err() { + break; // ERROR_NO_MORE_ITEMS, or nothing readable + } + // Subkey names are the browser's client name, which is usually + // the executable ("firefox.exe", "Google Chrome"). + let s = stem(&String::from_utf16_lossy(&buf[..len as usize])); + if !s.is_empty() && !out.contains(&s) { + out.push(s); + } + } + let _ = RegCloseKey(key); + } + } + out +} + +#[cfg(not(target_os = "windows"))] +fn discover_browsers() -> Vec { + // No equivalent registry on Linux; the analogue would be .desktop files + // registered for x-scheme-handler/http, if this is ever wanted there. + Vec::new() +} + +// ── running programs ──────────────────────────────────────────────────────── + +/// A running program: executable name plus its full image path when readable. +#[derive(Debug, Clone)] +pub struct RunningApp { + pub name: String, + pub path: Option, +} + +/// Running programs that belong to `group_id`. +pub fn running_in_group(group_id: &str) -> Vec { + running_apps() + .into_iter() + .filter(|a| matches(group_id, &a.name, a.path.as_deref())) + .collect() +} + +#[cfg(target_os = "windows")] +pub fn running_apps() -> Vec { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let mut out = Vec::new(); + unsafe { + let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + if !name.is_empty() { + out.push(RunningApp { path: process_path(entry.th32ProcessID), name }); + } + if Process32NextW(snap, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snap); + } + out +} + +/// Full image path of a process, or `None` when it can't be opened (elevated or +/// protected processes). Path heuristics simply don't apply to those. +#[cfg(target_os = "windows")] +pub fn process_path(pid: u32) -> Option { + use windows::core::PWSTR; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buf = [0u16; 1024]; + let mut len = buf.len() as u32; + let ok = + QueryFullProcessImageNameW(h, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len) + .is_ok(); + let _ = CloseHandle(h); + if !ok || len == 0 { + return None; + } + Some(String::from_utf16_lossy(&buf[..len as usize])) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn running_apps() -> Vec { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir("/proc") else { return out }; + for e in rd.flatten() { + let pid = e.file_name(); + let pid = pid.to_string_lossy(); + if !pid.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let path = std::fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .map(|p| p.display().to_string()); + // `comm` is truncated to 15 chars, so prefer the resolved exe name. + let name = path + .as_deref() + .and_then(|p| p.rsplit('/').next().map(str::to_string)) + .or_else(|| { + std::fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|c| c.trim().to_string()) + }) + .unwrap_or_default(); + if !name.is_empty() { + out.push(RunningApp { name, path }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_list_parses() { + assert!(!groups().is_empty(), "bundled app_groups.json should load"); + assert!(group("browsers").is_some()); + assert!(group("games").is_some()); + } + + #[test] + fn matches_by_name_stem() { + assert!(matches("browsers", "firefox.exe", None)); + assert!(matches("browsers", "chrome", None)); + assert!(matches("music", "Spotify.exe", None)); + assert!(!matches("music", "notepad.exe", None)); + } + + #[test] + fn exclusions_beat_name_and_path() { + // Steam itself lives in the game library but is a launcher, not a game. + assert!(!matches("games", "steam.exe", Some("C:\\Steam\\steamapps\\common\\x\\steam.exe"))); + assert!(!matches("browsers", "chrome_crashpad_handler.exe", None)); + } + + #[test] + fn matches_unknown_game_by_library_path() { + // The whole point: a game nobody listed, recognised by where it lives. + assert!(matches( + "games", + "SomeBrandNewGame.exe", + Some("D:\\SteamLibrary\\steamapps\\common\\Some Game\\SomeBrandNewGame.exe") + )); + assert!(!matches( + "games", + "SomeBrandNewGame.exe", + Some("C:\\tools\\SomeBrandNewGame.exe") + )); + } + + #[test] + fn group_refs_round_trip() { + let r = make_ref("games"); + assert_eq!(parse_ref(&r), Some("games")); + assert_eq!(parse_ref("spotify"), None); + assert_eq!(label_for(&r), "Games"); + assert_eq!(label_for("spotify"), "spotify"); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index f7efd16..1f6fbc7 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -44,6 +44,18 @@ pub trait AudioBackend: Send + Sync { None } + /// Stable id of the current default output endpoint (WASAPI endpoint id, + /// PulseAudio sink name, …), or `None` if the backend can't report one. + /// + /// The actuator polls this to notice that the default output changed — + /// whether Slidr's own "Cycle output device" action did it or the user + /// switched in the OS — so it can re-apply the volumes it last set. Sessions + /// migrate to the new endpoint carrying *its* levels, which is what makes + /// the slider positions and the actual volumes drift apart. + fn default_output_id(&self) -> Option { + None + } + fn list_processes(&self) -> Vec { Vec::new() } diff --git a/src/audio/wasapi.rs b/src/audio/wasapi.rs index 3e8b886..d3b9f4c 100644 --- a/src/audio/wasapi.rs +++ b/src/audio/wasapi.rs @@ -113,12 +113,19 @@ impl WasapiBackend { unsafe { dev.Activate::(CLSCTX_ALL, None) } } + /// Visit every audio session belonging to `process`. + /// + /// `process` is either a plain executable name (matched loosely, so + /// "chrome" finds "chrome.exe") or a program-group reference such as + /// `@group:games`, in which case every session whose program belongs to that + /// group is visited — see [`crate::app_groups`]. fn for_each_process_session( &self, process: &str, mut visit: F, ) { let _guard = self.lock.lock().unwrap(); + let group = crate::app_groups::parse_ref(process); let needle = strip_exe(&process.to_lowercase()); unsafe { let Ok(dev) = self.default_device(0) else { return }; @@ -138,9 +145,20 @@ impl WasapiBackend { continue; } let Some(name) = process_name(pid) else { continue }; - if strip_exe(&name.to_lowercase()).contains(&needle) - || needle.contains(&strip_exe(&name.to_lowercase())) - { + let hit = match group { + // Group membership also considers where the program lives + // (game libraries), so it needs the full image path. + Some(id) => crate::app_groups::matches( + id, + &name, + crate::app_groups::process_path(pid).as_deref(), + ), + None => { + strip_exe(&name.to_lowercase()).contains(&needle) + || needle.contains(&strip_exe(&name.to_lowercase())) + } + }; + if hit { visit(&ctl2, pid); } } @@ -236,15 +254,21 @@ impl AudioBackend for WasapiBackend { fn is_muted(&self, target: MuteTarget<'_>) -> bool { match target { MuteTarget::Process(p) => { - let mut muted = false; + // "Muted" means *every* matching session is muted. With several + // sessions — a browser's tabs, or a whole program group — that + // makes the toggle do the obvious thing: a mixed set mutes + // fully first, and only a fully muted set unmutes. + let mut seen = false; + let mut all_muted = true; self.for_each_process_session(p, |ctl, _| unsafe { if let Ok(vol) = ctl.cast::() { if let Ok(b) = vol.GetMute() { - muted = b.as_bool(); + seen = true; + all_muted &= b.as_bool(); } } }); - muted + seen && all_muted } MuteTarget::Mic(name) => { let _g = self.lock.lock().unwrap(); @@ -297,6 +321,11 @@ impl AudioBackend for WasapiBackend { } } + fn default_output_id(&self) -> Option { + let _g = self.lock.lock().unwrap(); + device_id(&self.default_device(0).ok()?) + } + fn list_outputs(&self) -> Vec { list_devices(&self.enumerator, 0) } diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..8b45464 --- /dev/null +++ b/src/discord.rs @@ -0,0 +1,582 @@ +//! Discord voice state ("am I muted?") as an LED condition source. +//! +//! Discord's desktop client exposes a local RPC socket — a named pipe on +//! Windows, a unix socket elsewhere — that carries the same voice state the +//! client shows. Nothing else reports Discord's *self*-mute: the OS only knows +//! about the microphone endpoint, which stays unmuted while Discord mutes you +//! internally. So the socket it is. +//! +//! Access is gated on an OAuth application, which the user creates once in the +//! Discord developer portal and enters in Slidr's settings: +//! +//! 1. → New Application, +//! 2. OAuth2 → add the redirect `http://localhost`, +//! 3. copy the Client ID and Client Secret into Slidr. +//! +//! On first connect Discord shows an authorize prompt; the resulting token is +//! cached in the config directory so later starts are silent. The secret is only +//! needed to obtain a token — once one is cached, it is no longer read, and +//! `SLIDR_DISCORD_CLIENT_SECRET` can supply it instead of the settings file. + +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::time::Duration; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Scopes we need: `rpc` to talk to the client at all, `rpc.voice.read` for the +/// voice settings. Both are read-only. +const SCOPES: [&str; 2] = ["rpc", "rpc.voice.read"]; +const REDIRECT_URI: &str = "http://localhost"; +const TOKEN_URL: &str = "https://discord.com/api/v10/oauth2/token"; + +/// Wait between reconnect attempts when Discord isn't running (or rejected us). +const RECONNECT_DELAY: Duration = Duration::from_secs(10); + +/// What an LED condition can ask about. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiscordSignal { + /// Self-muted in Discord (the client's own mute, not the OS mic). + #[default] + Muted, + /// Self-deafened. + Deafened, + /// Connected to a voice channel. + InVoice, +} + +impl DiscordSignal { + pub fn from_index(i: i32) -> Self { + match i { + 1 => Self::Deafened, + 2 => Self::InVoice, + _ => Self::Muted, + } + } + pub fn to_index(self) -> i32 { + match self { + Self::Muted => 0, + Self::Deafened => 1, + Self::InVoice => 2, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DiscordState { + /// The RPC socket is open and authenticated. + pub connected: bool, + pub muted: bool, + pub deafened: bool, + pub in_voice: bool, +} + +impl DiscordState { + pub fn get(&self, signal: DiscordSignal) -> bool { + if !self.connected { + return false; + } + match signal { + DiscordSignal::Muted => self.muted, + DiscordSignal::Deafened => self.deafened, + DiscordSignal::InVoice => self.in_voice, + } + } +} + +static STATE: OnceLock> = OnceLock::new(); +static CONFIG: OnceLock>> = OnceLock::new(); +static RUNNING: AtomicBool = AtomicBool::new(false); +/// Bumped whenever the credentials change, so an older worker retires itself. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, PartialEq, Eq)] +struct Credentials { + client_id: String, + client_secret: String, +} + +fn state_cell() -> &'static Mutex { + STATE.get_or_init(|| Mutex::new(DiscordState::default())) +} + +fn config_cell() -> &'static Mutex> { + CONFIG.get_or_init(|| Mutex::new(None)) +} + +pub fn state() -> DiscordState { + *state_cell().lock() +} + +/// Does this look like a Discord application id? They are snowflakes — a run of +/// digits — and settings are saved on every keystroke, so a half-typed id must +/// not send us off to open a socket and fail. +pub fn is_valid_id(client_id: &str) -> bool { + let id = client_id.trim(); + id.len() >= 15 && id.len() <= 25 && id.chars().all(|c| c.is_ascii_digit()) +} + +/// Point the worker at a Discord application. Called whenever settings change; +/// a blank (or not-yet-complete) client id stops the worker. The secret may also +/// come from `SLIDR_DISCORD_CLIENT_SECRET`, which takes precedence. +pub fn configure(client_id: &str, client_secret: &str) { + let secret = std::env::var("SLIDR_DISCORD_CLIENT_SECRET") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| client_secret.to_string()); + let next = is_valid_id(client_id).then(|| Credentials { + client_id: client_id.trim().to_string(), + client_secret: secret, + }); + + let mut slot = config_cell().lock(); + if *slot == next { + return; + } + *slot = next.clone(); + GENERATION.fetch_add(1, Ordering::SeqCst); + *state_cell().lock() = DiscordState::default(); + drop(slot); + + if next.is_none() { + log::info!("discord: no usable client id; voice state disabled"); + return; + } + // One worker for the process; it re-reads the credentials each round. + if !RUNNING.swap(true, Ordering::SeqCst) { + let _ = std::thread::Builder::new() + .name("slidr-discord".into()) + .spawn(worker); + } +} + +fn worker() { + loop { + let generation = GENERATION.load(Ordering::SeqCst); + let creds = config_cell().lock().clone(); + let Some(creds) = creds else { + std::thread::sleep(RECONNECT_DELAY); + continue; + }; + match session(&creds, generation) { + Ok(()) => log::info!("discord: session ended"), + Err(e) => log::warn!("discord: {e}"), + } + *state_cell().lock() = DiscordState::default(); + std::thread::sleep(RECONNECT_DELAY); + } +} + +/// One connect → authenticate → subscribe → read-loop cycle. Returns when the +/// socket closes or the credentials change under us. +fn session(creds: &Credentials, generation: u64) -> anyhow::Result<()> { + let mut conn = Connection::open(&creds.client_id)?; + log::info!("discord: connected to the local RPC socket"); + + let token = authenticate(&mut conn, creds)?; + save_token(&token); + state_cell().lock().connected = true; + log::info!("discord: authenticated"); + + // Current values first, then the live updates. + conn.send_cmd("GET_VOICE_SETTINGS", json!({}))?; + conn.send_cmd("GET_SELECTED_VOICE_CHANNEL", json!({}))?; + conn.subscribe("VOICE_SETTINGS_UPDATE")?; + conn.subscribe("VOICE_CHANNEL_SELECT")?; + + loop { + if GENERATION.load(Ordering::SeqCst) != generation { + return Ok(()); // credentials changed; the worker restarts us + } + let (_, msg) = conn.recv()?; + apply_message(&msg); + } +} + +/// Fold one RPC message into the shared state. Voice settings arrive both as a +/// command response and as a subscribed event, with the same payload shape. +fn apply_message(msg: &Value) { + let evt = msg.get("evt").and_then(Value::as_str).unwrap_or(""); + let cmd = msg.get("cmd").and_then(Value::as_str).unwrap_or(""); + let data = msg.get("data"); + + if evt == "VOICE_SETTINGS_UPDATE" || cmd == "GET_VOICE_SETTINGS" { + if let Some(d) = data { + let mut s = state_cell().lock(); + if let Some(m) = d.get("mute").and_then(Value::as_bool) { + s.muted = m; + } + if let Some(d2) = d.get("deaf").and_then(Value::as_bool) { + s.deafened = d2; + // Deafening implies muted in the client's own display. + if d2 { + s.muted = true; + } + } + } + } + if evt == "VOICE_CHANNEL_SELECT" || cmd == "GET_SELECTED_VOICE_CHANNEL" { + // `data` is null (or has no channel id) when leaving a channel. + let in_voice = data + .and_then(|d| d.get("channel_id").or_else(|| d.get("id"))) + .is_some_and(|v| !v.is_null()); + state_cell().lock().in_voice = in_voice; + } +} + +// ── authentication ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct StoredToken { + #[serde(default)] + access_token: String, + #[serde(default)] + refresh_token: String, +} + +fn token_path() -> std::path::PathBuf { + crate::storage::config_root().join("discord_token.json") +} + +fn load_token() -> StoredToken { + std::fs::read_to_string(token_path()) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() +} + +fn save_token(token: &StoredToken) { + let path = token_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let Ok(json) = serde_json::to_string_pretty(token) else { return }; + if std::fs::write(&path, json).is_err() { + log::warn!("discord: could not cache the access token; re-authorising next start"); + return; + } + restrict(&path); +} + +/// Make the token file owner-only where the platform expresses that in the mode +/// bits. On Windows the config directory is already per-user. +#[cfg(unix)] +fn restrict(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} + +#[cfg(not(unix))] +fn restrict(_path: &std::path::Path) {} + +/// Authenticate the open connection, re-using the cached token when it still +/// works and falling back to a fresh authorize flow (which prompts the user in +/// Discord) otherwise. +fn authenticate(conn: &mut Connection, creds: &Credentials) -> anyhow::Result { + let cached = load_token(); + if !cached.access_token.is_empty() && try_authenticate(conn, &cached.access_token)? { + return Ok(cached); + } + if !cached.refresh_token.is_empty() { + match exchange(creds, [("grant_type", "refresh_token"), ("refresh_token", &cached.refresh_token)]) { + Ok(fresh) => { + if try_authenticate(conn, &fresh.access_token)? { + return Ok(fresh); + } + } + Err(e) => log::info!("discord: token refresh failed ({e}); asking for authorisation"), + } + } + + if creds.client_secret.is_empty() { + anyhow::bail!( + "no usable token and no client secret — add the secret once (or set \ + SLIDR_DISCORD_CLIENT_SECRET) so Slidr can complete the authorisation" + ); + } + log::info!("discord: requesting authorisation — confirm the prompt in Discord"); + conn.send_cmd( + "AUTHORIZE", + json!({ "client_id": creds.client_id, "scopes": SCOPES }), + )?; + let code = loop { + let (_, msg) = conn.recv()?; + if msg.get("cmd").and_then(Value::as_str) == Some("AUTHORIZE") { + if let Some(err) = rpc_error(&msg) { + anyhow::bail!("authorisation refused: {err}"); + } + match msg.pointer("/data/code").and_then(Value::as_str) { + Some(c) => break c.to_string(), + None => anyhow::bail!("authorisation response carried no code"), + } + } + apply_message(&msg); + }; + + let token = exchange( + creds, + [("grant_type", "authorization_code"), ("code", &code)], + )?; + if !try_authenticate(conn, &token.access_token)? { + anyhow::bail!("the freshly issued token was rejected"); + } + Ok(token) +} + +/// Send AUTHENTICATE and report whether it was accepted. +fn try_authenticate(conn: &mut Connection, access_token: &str) -> anyhow::Result { + conn.send_cmd("AUTHENTICATE", json!({ "access_token": access_token }))?; + loop { + let (_, msg) = conn.recv()?; + if msg.get("cmd").and_then(Value::as_str) == Some("AUTHENTICATE") { + if let Some(err) = rpc_error(&msg) { + log::info!("discord: cached token rejected ({err})"); + return Ok(false); + } + return Ok(true); + } + apply_message(&msg); + } +} + +fn rpc_error(msg: &Value) -> Option { + if msg.get("evt").and_then(Value::as_str) != Some("ERROR") { + return None; + } + Some( + msg.pointer("/data/message") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(), + ) +} + +/// OAuth token endpoint. `extra` carries the grant-specific fields. +fn exchange(creds: &Credentials, extra: [(&str, &str); 2]) -> anyhow::Result { + let mut form: Vec<(&str, &str)> = vec![ + ("client_id", creds.client_id.as_str()), + ("client_secret", creds.client_secret.as_str()), + ("redirect_uri", REDIRECT_URI), + ]; + form.extend_from_slice(&extra); + let resp = ureq::post(TOKEN_URL) + .send_form(&form) + .map_err(|e| match e { + // The body can echo request fields — never let it reach a log. + ureq::Error::Status(code, _) => { + anyhow::anyhow!("token endpoint returned HTTP {code}") + } + other => anyhow::anyhow!("token request failed: {other}"), + })?; + // ureq's `into_json` needs its optional json feature; we already depend on + // serde_json, so parse the body ourselves. + let body: Value = serde_json::from_str(&resp.into_string()?)?; + let access_token = body + .get("access_token") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if access_token.is_empty() { + anyhow::bail!("token endpoint returned no access_token"); + } + Ok(StoredToken { + access_token, + refresh_token: body + .get("refresh_token") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) +} + +// ── the socket ────────────────────────────────────────────────────────────── + +/// Discord's IPC framing: little-endian opcode, little-endian length, JSON body. +const OP_HANDSHAKE: u32 = 0; +const OP_FRAME: u32 = 1; +const OP_CLOSE: u32 = 2; +const OP_PING: u32 = 3; +const OP_PONG: u32 = 4; + +/// Discord's own cap is 64 KiB per frame; refuse anything larger rather than +/// allocating whatever a length prefix claims. +const MAX_FRAME: u32 = 64 * 1024; + +struct Connection { + io: Box, + nonce: u64, +} + +trait ReadWrite: Read + Write + Send {} +impl ReadWrite for T {} + +impl Connection { + /// Open the first available RPC socket and complete the handshake. Discord + /// numbers its sockets 0-9; several can exist with more than one client + /// (stable next to PTB/Canary). + fn open(client_id: &str) -> anyhow::Result { + let mut last: Option = None; + for i in 0..10 { + match connect_socket(i) { + Ok(io) => { + let mut conn = Self { io, nonce: 0 }; + conn.send(OP_HANDSHAKE, &json!({ "v": 1, "client_id": client_id }))?; + let (_, msg) = conn.recv()?; + if let Some(err) = rpc_error(&msg) { + anyhow::bail!("handshake refused: {err}"); + } + return Ok(conn); + } + Err(e) => last = Some(e.to_string()), + } + } + anyhow::bail!( + "no Discord RPC socket found (is Discord running?){}", + last.map(|e| format!(": {e}")).unwrap_or_default() + ) + } + + fn next_nonce(&mut self) -> String { + self.nonce += 1; + format!("slidr-{}", self.nonce) + } + + fn send_cmd(&mut self, cmd: &str, args: Value) -> anyhow::Result<()> { + let nonce = self.next_nonce(); + self.send(OP_FRAME, &json!({ "cmd": cmd, "args": args, "nonce": nonce })) + } + + fn subscribe(&mut self, evt: &str) -> anyhow::Result<()> { + let nonce = self.next_nonce(); + self.send( + OP_FRAME, + &json!({ "cmd": "SUBSCRIBE", "evt": evt, "args": {}, "nonce": nonce }), + ) + } + + fn send(&mut self, op: u32, payload: &Value) -> anyhow::Result<()> { + let body = serde_json::to_vec(payload)?; + let mut frame = Vec::with_capacity(8 + body.len()); + frame.extend_from_slice(&op.to_le_bytes()); + frame.extend_from_slice(&(body.len() as u32).to_le_bytes()); + frame.extend_from_slice(&body); + self.io.write_all(&frame)?; + self.io.flush()?; + Ok(()) + } + + /// Read one frame, answering pings inline so the caller only ever sees data. + fn recv(&mut self) -> anyhow::Result<(u32, Value)> { + loop { + let mut header = [0u8; 8]; + self.io.read_exact(&mut header)?; + let op = u32::from_le_bytes(header[..4].try_into().unwrap()); + let len = u32::from_le_bytes(header[4..].try_into().unwrap()); + if len > MAX_FRAME { + anyhow::bail!("oversized RPC frame ({len} bytes)"); + } + let mut body = vec![0u8; len as usize]; + self.io.read_exact(&mut body)?; + match op { + OP_CLOSE => anyhow::bail!("the client closed the connection"), + OP_PING => { + let echo: Value = serde_json::from_slice(&body).unwrap_or_else(|_| json!({})); + self.send(OP_PONG, &echo)?; + } + OP_PONG => {} + _ => { + let msg: Value = serde_json::from_slice(&body)?; + return Ok((op, msg)); + } + } + } + } +} + +#[cfg(target_os = "windows")] +fn connect_socket(index: u32) -> anyhow::Result> { + use std::fs::OpenOptions; + let path = format!(r"\\.\pipe\discord-ipc-{index}"); + let pipe = OpenOptions::new().read(true).write(true).open(&path)?; + Ok(Box::new(pipe)) +} + +#[cfg(not(target_os = "windows"))] +fn connect_socket(index: u32) -> anyhow::Result> { + use std::os::unix::net::UnixStream; + // Flatpak and Snap relocate the socket one directory down. + let base = ["XDG_RUNTIME_DIR", "TMPDIR"] + .iter() + .find_map(|k| std::env::var(k).ok()) + .unwrap_or_else(|| "/tmp".to_string()); + let mut last = None; + for sub in ["", "app/com.discordapp.Discord/", "snap.discord/"] { + let path = format!("{}/{sub}discord-ipc-{index}", base.trim_end_matches('/')); + match UnixStream::connect(&path) { + Ok(s) => return Ok(Box::new(s)), + Err(e) => last = Some(e), + } + } + Err(last + .map(anyhow::Error::from) + .unwrap_or_else(|| anyhow::anyhow!("no socket path tried"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_id_validation() { + assert!(is_valid_id("123456789012345678")); + assert!(is_valid_id(" 123456789012345678 ")); + // A half-typed id must not start a connection attempt. + assert!(!is_valid_id("1234")); + assert!(!is_valid_id("")); + assert!(!is_valid_id("not-a-snowflake-1234")); + } + + #[test] + fn signal_index_round_trip() { + for s in [DiscordSignal::Muted, DiscordSignal::Deafened, DiscordSignal::InVoice] { + assert_eq!(DiscordSignal::from_index(s.to_index()), s); + } + } + + #[test] + fn state_is_false_while_disconnected() { + let s = DiscordState { connected: false, muted: true, deafened: true, in_voice: true }; + assert!(!s.get(DiscordSignal::Muted)); + let s = DiscordState { connected: true, ..s }; + assert!(s.get(DiscordSignal::Muted)); + } + + #[test] + fn voice_settings_update_folds_in() { + *state_cell().lock() = DiscordState { connected: true, ..Default::default() }; + apply_message(&json!({ "evt": "VOICE_SETTINGS_UPDATE", "data": { "mute": true, "deaf": false } })); + assert!(state().muted); + assert!(!state().deafened); + + // Deafening implies muted, the way the client shows it. + apply_message(&json!({ "evt": "VOICE_SETTINGS_UPDATE", "data": { "mute": false, "deaf": true } })); + assert!(state().deafened); + assert!(state().muted); + + apply_message(&json!({ "evt": "VOICE_CHANNEL_SELECT", "data": { "channel_id": "123" } })); + assert!(state().in_voice); + apply_message(&json!({ "evt": "VOICE_CHANNEL_SELECT", "data": null })); + assert!(!state().in_voice); + } + + #[test] + fn error_frames_are_recognised() { + let msg = json!({ "cmd": "AUTHENTICATE", "evt": "ERROR", "data": { "message": "nope" } }); + assert_eq!(rpc_error(&msg).as_deref(), Some("nope")); + assert!(rpc_error(&json!({ "cmd": "AUTHENTICATE" })).is_none()); + } +} diff --git a/src/glue.rs b/src/glue.rs index 8acf397..7464d7e 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -307,6 +307,7 @@ pub fn wire( ui.set_wizard_kind(0); ui.set_wizard_property("".into()); ui.set_wizard_display("".into()); + ui.set_wizard_picked_group(false); reset_wizard_api(&ui); populate_live_lists(&ui, shared.clone()); ui.set_wizard_filter("".into()); @@ -354,6 +355,7 @@ pub fn wire( ui.set_wizard_category_id(cat_id as i32); ui.set_wizard_kind(wkind); ui.set_wizard_step(1); // jump straight to the target step + ui.set_wizard_picked_group(crate::app_groups::parse_ref(&prop).is_some()); ui.set_wizard_property(prop.into()); ui.set_wizard_display(disp.into()); push_wizard_api(&ui, api.as_ref()); @@ -638,6 +640,10 @@ pub fn wire( apply_appearance(&ui, &s.preset.settings); // LED brightness / experimental flag may have changed. s.push_leds(); + // Re-point the Discord worker if the application changed (a blank id + // stops it). Cheap and idempotent when nothing moved. + crate::discord::configure(&s.settings.discord_client_id, &s.settings.discord_client_secret); + ui.set_discord_status(discord_hint(&s.settings).into()); }); } { @@ -745,6 +751,7 @@ pub fn wire( push_mode_to_ui(&ui, &cfg.active, true); push_mode_to_ui(&ui, &cfg.inactive, false); push_led_conditions(&ui, &cfg.conditions); + ui.set_led_discord_hint(discord_hint(&shared.lock().settings).into()); ui.set_led_live_active(false); ui.set_led_popup_open(true); }); @@ -832,6 +839,7 @@ pub fn wire( if let Some(conds) = conds { push_led_conditions(&ui, &conds); } + ui.set_led_discord_hint(discord_hint(&shared.lock().settings).into()); ui.set_led_active_btidx(list_index_of(&ui, ui.get_led_active_baudio(), ui.get_led_active_btarget().as_str())); ui.set_led_inactive_btidx(list_index_of(&ui, ui.get_led_inactive_baudio(), ui.get_led_inactive_btarget().as_str())); }); @@ -884,6 +892,21 @@ pub fn wire( let shared = shared.clone(); ui.on_led_cond_cmp(move |idx, v| { mutate_cond(&shared, idx, |c| c.cmp = cmp_from(v)); }); } + { + let shared = shared.clone(); + // Media source filter (free text; empty = any player). + ui.on_led_cond_media(move |idx, v| { mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); }); + } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_led_cond_signal(move |idx, v| { + let conds = mutate_cond_struct(&shared, idx, |c| { + c.discord = crate::discord::DiscordSignal::from_index(v) + }); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } { let shared = shared.clone(); ui.on_led_cond_value(move |idx, v| { mutate_cond(&shared, idx, |c| c.value = v.clamp(0, 100) as u8); }); @@ -1109,7 +1132,7 @@ fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { id: i as i32, primary: SharedString::from(a.kind.label()), secondary: action_secondary(a).into(), - icon_kind: action_icon_kind(a.kind), + icon_kind: action_icon_kind_for(a), }).collect::>(), )), }) @@ -1124,6 +1147,7 @@ fn push_preset_to_ui(ui: &AppWindow, preset: &Preset) { fn stream_icon_kind(s: &AudioStream) -> i32 { if s.api.is_some() { 3 } + else if s.process.as_deref().is_some_and(|p| crate::app_groups::parse_ref(p).is_some()) { 6 } else if s.process.is_some() { 0 } else if s.mic_name.is_some() { 1 } else { 2 } @@ -1145,9 +1169,20 @@ fn action_secondary(a: &crate::model::ButtonAction) -> String { if a.kind == ActionKind::ApiCall { return a.api.as_ref().map(|api| api.label()).unwrap_or_else(|| "API call".into()); } + // A category stores an "@group:" reference; show the category's name. + if let Some(id) = a.property.as_deref().and_then(crate::app_groups::parse_ref) { + return format!("{} (category)", crate::app_groups::display_name(id)); + } a.display.clone().or_else(|| a.property.clone()).unwrap_or_default() } +fn action_icon_kind_for(a: &crate::model::ButtonAction) -> i32 { + if a.property.as_deref().and_then(crate::app_groups::parse_ref).is_some() { + return 6; + } + action_icon_kind(a.kind) +} + fn action_icon_kind(k: ActionKind) -> i32 { use ActionKind::*; match k { @@ -1159,8 +1194,11 @@ fn action_icon_kind(k: ActionKind) -> i32 { fn stream_secondary(s: &AudioStream) -> String { if s.api.is_some() { "API call".into() - } else if s.process.is_some() { - "process".into() + } else if let Some(p) = &s.process { + match crate::app_groups::parse_ref(p) { + Some(_) => "program category".into(), + None => "process".into(), + } } else if s.mic_name.is_some() { "microphone".into() } else if s.device_name.is_some() { @@ -1181,6 +1219,9 @@ fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model ui.set_start_minimized(global.start_minimized); ui.set_minimize_to_tray(global.minimize_to_tray); ui.set_led_experimental(global.led_experimental); + ui.set_discord_client_id(global.discord_client_id.clone().into()); + ui.set_discord_client_secret(global.discord_client_secret.clone().into()); + ui.set_discord_status(discord_hint(global).into()); ui.set_active_preset_name(global.active_preset.clone().into()); // Appearance + sliders (profile) ui.set_theme_index(match profile.theme { @@ -1208,6 +1249,8 @@ fn pull_global_from_ui(ui: &AppWindow, g: &mut Settings) { g.start_minimized = ui.get_start_minimized(); g.minimize_to_tray = ui.get_minimize_to_tray(); g.led_experimental = ui.get_led_experimental(); + g.discord_client_id = ui.get_discord_client_id().to_string(); + g.discord_client_secret = ui.get_discord_client_secret().to_string(); } fn pull_profile_from_ui(ui: &AppWindow, p: &mut crate::model::ProfileSettings) { @@ -1312,6 +1355,9 @@ fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { (1, 0) => merged_processes(s.audio.as_ref()), (1, 1) => s.audio.list_outputs(), (1, 2) => s.audio.list_mics(), + // Open application — running programs, stored by full path so the entry + // survives a PATH lookup failing later. + (1, 3) => Vec::new(), // Simulate key — full library (1, 5) => crate::keys_library::KEYS.iter().map(|k| k.name.to_string()).collect(), // Cycle output device — multi-select list of outputs @@ -1340,6 +1386,48 @@ fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { filtered.sort(); filtered.dedup(); } + // Program-category rows, offered wherever the target is a program. They sit + // at the top of the list: a category is the answer to "I don't want to redo + // this every time I install a game". + let groups: Vec = if is_program_kind(target_kind, kind) { + crate::app_groups::groups() + .iter() + .filter(|g| filter_norm.is_empty() || g.name.to_lowercase().contains(filter_norm)) + .map(|g| crate::PickerEntry { + selected: false, + name: g.name.clone().into(), + value: crate::app_groups::make_ref(&g.id).into(), + icon_kind: g.icon, + is_group: true, + }) + .collect() + } else { + Vec::new() + }; + + // "Open application" launches an executable, so its rows carry the full path + // as the stored value while still reading as the program name. + let program_rows: Vec = if target_kind == 1 && kind == 3 { + let mut apps: Vec<(String, String)> = crate::app_groups::running_apps() + .into_iter() + .filter_map(|a| a.path.map(|p| (a.name, p))) + .filter(|(n, _)| filter_norm.is_empty() || n.to_lowercase().contains(filter_norm)) + .collect(); + apps.sort(); + apps.dedup(); + apps.into_iter() + .map(|(name, path)| crate::PickerEntry { + selected: false, + name: name.into(), + value: path.into(), + icon_kind: 0, + is_group: false, + }) + .collect() + } else { + Vec::new() + }; + let icon_kind: i32 = match (target_kind, kind) { (_, 1) if target_kind == 0 => 1, // slider mic (0, 2) => 2, // slider output @@ -1349,15 +1437,25 @@ fn push_wizard_picker(ui: &AppWindow, shared: Arc>) { (1, 6) => 2, // cycle output _ => 0, // process }; - ui.set_wizard_picker_source(ModelRc::new(VecModel::from( - filtered.into_iter() - .map(|n| crate::PickerEntry { - selected: chosen.iter().any(|c| c == &n), - name: n.into(), - icon_kind, - }) - .collect::>(), - ))); + let rows: Vec = groups + .into_iter() + .chain(program_rows) + .chain(filtered.into_iter().map(|n| crate::PickerEntry { + selected: chosen.iter().any(|c| c == &n), + value: n.clone().into(), + name: n.into(), + icon_kind, + is_group: false, + })) + .collect(); + ui.set_wizard_picker_source(ModelRc::new(VecModel::from(rows))); +} + +/// Wizard kinds whose target is a program, and which therefore accept a +/// program category: slider "Process volume", button "Mute process" and button +/// "Open application". +fn is_program_kind(target_kind: i32, kind: i32) -> bool { + matches!((target_kind, kind), (0, 0) | (1, 0) | (1, 3)) } /// Merge sessions-with-audio with all running processes, so the picker isn't @@ -1509,7 +1607,13 @@ fn led_mode_from_ui( } fn cond_kind_from(v: i32) -> LedConditionKind { - match v { 1 => LedConditionKind::Volume, 2 => LedConditionKind::Api, _ => LedConditionKind::Muted } + match v { + 1 => LedConditionKind::Volume, + 2 => LedConditionKind::Api, + 3 => LedConditionKind::Media, + 4 => LedConditionKind::Discord, + _ => LedConditionKind::Muted, + } } fn audio_kind_from(v: i32) -> AudioKind { match v { 1 => AudioKind::Mic, 2 => AudioKind::Output, _ => AudioKind::Process } @@ -1520,7 +1624,13 @@ fn cmp_from(v: i32) -> Comparison { fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { LedConditionUI { - kind: match c.kind { LedConditionKind::Muted => 0, LedConditionKind::Volume => 1, LedConditionKind::Api => 2 }, + kind: match c.kind { + LedConditionKind::Muted => 0, + LedConditionKind::Volume => 1, + LedConditionKind::Api => 2, + LedConditionKind::Media => 3, + LedConditionKind::Discord => 4, + }, audio_kind: audio_kind_to(c.audio_kind), target: c.target.clone().unwrap_or_default().into(), target_index, @@ -1532,6 +1642,32 @@ fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { bearer: c.api.bearer.clone().unwrap_or_default().into(), interval: c.api.interval_ms.to_string().into(), expect_body: c.api.expect_body.clone().unwrap_or_default().into(), + signal: c.discord.to_index(), + } +} + +/// One-line hints under the two new condition sources, so the popup can say why +/// a source isn't producing anything yet. +fn media_hint() -> String { + let sources = crate::media::sources(); + if sources.is_empty() { + "No media session detected yet — start playback, then Refresh.".into() + } else { + format!("Detected: {}", sources.join(", ")) + } +} + +fn discord_hint(settings: &Settings) -> String { + if settings.discord_client_id.trim().is_empty() { + return "Not set up — add a Discord application under Settings → LEDs.".into(); + } + if !crate::discord::is_valid_id(&settings.discord_client_id) { + return "That Client ID doesn't look right — it is a long number from the developer portal.".into(); + } + if crate::discord::state().connected { + "Connected to Discord.".into() + } else { + "Waiting for Discord — is it running, and did you confirm the authorisation prompt?".into() } } @@ -1541,11 +1677,19 @@ fn push_led_conditions(ui: &AppWindow, conds: &[LedCondition]) { let rows: Vec = conds .iter() .map(|c| { - let tidx = list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")); + // Only the audio kinds pick from a live list; the media filter is + // free text and Discord has no target at all. + let tidx = match c.kind { + LedConditionKind::Muted | LedConditionKind::Volume => { + list_index_of(ui, audio_kind_to(c.audio_kind), c.target.as_deref().unwrap_or("")) + } + _ => -1, + }; cond_to_ui(c, tidx) }) .collect(); ui.set_led_conditions(ModelRc::new(VecModel::from(rows))); + ui.set_led_media_hint(media_hint().into()); } /// Push one LED appearance into the UI scalar props (active or inactive). diff --git a/src/led.rs b/src/led.rs index bd4851e..524ebc6 100644 --- a/src/led.rs +++ b/src/led.rs @@ -4,7 +4,9 @@ //! the board over the [`SerialLink`] (see `LED-Steuerung.md`). Runs on the //! actuator thread, ticked ~10×/s, so audio-state polling and HTTP checks never //! block the UI. API conditions are polled on detached threads at their own -//! interval; the tick only reads the cached boolean. +//! interval; the tick only reads the cached boolean. The media-session and +//! Discord sources work the same way — see [`crate::media`] and +//! [`crate::discord`]. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -249,6 +251,12 @@ impl LedEngine { } } LedConditionKind::Api => self.eval_api(led, idx, cond), + // Both of these are served from a cache kept by a background + // worker, so the tick never waits on WinRT or a socket. + LedConditionKind::Media => { + crate::media::state().is_playing(cond.target.as_deref()) + } + LedConditionKind::Discord => crate::discord::state().get(cond.discord), } } diff --git a/src/main.rs b/src/main.rs index cb508fa..7b0d5c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,15 +10,18 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] mod actuator; +mod app_groups; mod audio; mod autostart; mod curve; +mod discord; mod events; mod glue; mod keys; mod keys_library; mod keys_vk; mod led; +mod media; mod model; mod protocol; mod serial; @@ -120,6 +123,10 @@ fn main() -> anyhow::Result<()> { autostart::resync(settings.start_minimized); } + // Start the Discord voice-state worker if an application is configured; a + // blank client id leaves it stopped (the LED source then never fires). + discord::configure(&settings.discord_client_id, &settings.discord_client_secret); + let shared = Arc::new(Mutex::new(glue::Shared { settings, preset, @@ -284,7 +291,7 @@ fn main() -> anyhow::Result<()> { // System tray (Windows). Held for the process lifetime. #[cfg(target_os = "windows")] - let _tray = tray::install(&ui); + let _tray = tray::install(&ui, start_hidden); // Close handling. On Windows with the tray + minimize-to-tray enabled, // closing hides the window instead of quitting. Otherwise it quits. diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..70f0f36 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,145 @@ +//! OS media-session state ("is music playing?") as an LED condition source. +//! +//! Windows exposes every app that publishes transport controls — Spotify, a +//! browser tab, a desktop player — through the System Media Transport Controls +//! (SMTC). Reading that instead of talking to Spotify's own API means no login, +//! no API key, and it works for whatever the user actually plays music with. +//! +//! The WinRT calls block on an async operation, so they run on a dedicated +//! poller thread and everything else reads the cached snapshot. Non-Windows +//! builds report "nothing playing" (the MPRIS equivalent would go here). + +use std::sync::OnceLock; +use std::time::Duration; + +use parking_lot::Mutex; + +/// How often the media session list is re-read. +const POLL: Duration = Duration::from_millis(1000); + +#[derive(Debug, Clone, Default)] +pub struct MediaState { + /// Source ids (Windows: the app user-model id) that are playing right now. + pub playing: Vec, + /// All known media sources, playing or not — used to populate the picker. + pub sources: Vec, +} + +impl MediaState { + /// Is anything playing? With `filter` set, only that source counts + /// (case-insensitive substring, so "spotify" matches "Spotify.exe"). + pub fn is_playing(&self, filter: Option<&str>) -> bool { + match filter.map(str::trim).filter(|f| !f.is_empty()) { + None => !self.playing.is_empty(), + Some(f) => { + let f = f.to_lowercase(); + self.playing.iter().any(|s| s.to_lowercase().contains(&f)) + } + } + } +} + +static STATE: OnceLock> = OnceLock::new(); + +/// Latest snapshot. The first call starts the poller thread, so a caller that +/// asks immediately after startup may see an empty state for up to one poll. +pub fn state() -> MediaState { + let cell = STATE.get_or_init(|| { + start_poller(); + Mutex::new(MediaState::default()) + }); + cell.lock().clone() +} + +/// Media sources the OS knows about, for the condition's target picker. +pub fn sources() -> Vec { + state().sources +} + +fn start_poller() { + // Nothing to poll where there is no media-session API — don't burn a thread + // (and a wakeup per second) re-reading an empty answer. + if cfg!(not(target_os = "windows")) { + return; + } + let _ = std::thread::Builder::new() + .name("slidr-media".into()) + .spawn(|| { + // Own apartment: the WinRT calls below block on an async operation, + // which must not happen on the UI thread's STA. + #[cfg(target_os = "windows")] + unsafe { + use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + loop { + let next = read_sessions(); + if let Some(cell) = STATE.get() { + *cell.lock() = next; + } + std::thread::sleep(POLL); + } + }); +} + +#[cfg(target_os = "windows")] +fn read_sessions() -> MediaState { + use windows::Media::Control::{ + GlobalSystemMediaTransportControlsSessionManager as SessionManager, + GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus, + }; + + let mut out = MediaState::default(); + let manager = match SessionManager::RequestAsync().and_then(|op| op.get()) { + Ok(m) => m, + Err(e) => { + log::debug!("media: session manager unavailable: {e}"); + return out; + } + }; + let Ok(sessions) = manager.GetSessions() else { return out }; + for session in sessions { + let Ok(id) = session.SourceAppUserModelId() else { continue }; + let id = id.to_string(); + if id.is_empty() { + continue; + } + if !out.sources.contains(&id) { + out.sources.push(id.clone()); + } + let playing = session + .GetPlaybackInfo() + .and_then(|info| info.PlaybackStatus()) + .map(|status| status == PlaybackStatus::Playing) + .unwrap_or(false); + if playing && !out.playing.contains(&id) { + out.playing.push(id); + } + } + out +} + +#[cfg(not(target_os = "windows"))] +fn read_sessions() -> MediaState { + // Linux would read MPRIS over D-Bus (org.mpris.MediaPlayer2.*); Slidr has no + // D-Bus dependency today, so the condition simply never fires there. + MediaState::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_matching() { + let s = MediaState { + playing: vec!["Spotify.exe".into()], + sources: vec!["Spotify.exe".into(), "firefox.exe".into()], + }; + assert!(s.is_playing(None)); + assert!(s.is_playing(Some("spotify"))); + assert!(s.is_playing(Some(" ")), "blank filter means any source"); + assert!(!s.is_playing(Some("firefox"))); + assert!(!MediaState::default().is_playing(None)); + } +} diff --git a/src/model.rs b/src/model.rs index eeb89a3..d4b3887 100644 --- a/src/model.rs +++ b/src/model.rs @@ -7,7 +7,9 @@ use crate::curve::{BezierPoints, CurvePreset}; /// Audio target on a slider. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct AudioStream { - /// Process name (e.g. `firefox`) or absolute path to an executable. + /// Process name (e.g. `firefox`), absolute path to an executable, or a + /// program-category reference (`@group:`, see [`crate::app_groups`]) + /// that follows every member of the category. #[serde(default, skip_serializing_if = "Option::is_none")] pub process: Option, /// Microphone friendly name. @@ -27,7 +29,7 @@ impl AudioStream { if let Some(api) = &self.api { api.label() } else if let Some(p) = &self.process { - p.clone() + crate::app_groups::label_for(p) } else if let Some(m) = &self.mic_name { format!("mic: {m}") } else if let Some(d) = &self.device_name { @@ -156,7 +158,7 @@ impl ActionKind { pub struct ButtonAction { pub kind: ActionKind, /// Payload, semantics vary by kind: - /// * `MuteProcess` / `OpenProcess` → process name or path + /// * `MuteProcess` / `OpenProcess` → process name, path, or `@group:` /// * `MuteMainAudio` → optional device name /// * `MuteMic` → mic friendly name /// * `OpenWebsite` → URL @@ -310,6 +312,11 @@ pub enum LedConditionKind { Muted, Volume, Api, + /// Something is playing through the OS media session (Spotify, a browser + /// tab, any player that publishes transport controls). + Media, + /// Discord voice state (self-mute / deafen / in a voice channel). + Discord, } /// HTTP poll used by an `Api` condition. Mirrors a button "API call", plus the @@ -360,7 +367,8 @@ pub struct LedCondition { /// For `Muted`/`Volume`: which endpoint family. #[serde(default)] pub audio_kind: AudioKind, - /// Process/mic/output device name. None = default output endpoint. + /// Process/mic/output device name, or — for `Media` — the media source to + /// watch (empty/None = any source). None = default output endpoint. #[serde(default, skip_serializing_if = "Option::is_none")] pub target: Option, /// For `Volume`: comparison against `value`. @@ -372,6 +380,9 @@ pub struct LedCondition { /// For `Api`. #[serde(default)] pub api: LedApi, + /// For `Discord`: which part of the voice state to follow. + #[serde(default)] + pub discord: crate::discord::DiscordSignal, } /// Per-LED configuration. Conditions decide the `active` vs `inactive` look. @@ -471,6 +482,14 @@ pub struct Settings { #[serde(default)] pub led_experimental: bool, + /// Discord application used for the LED "Discord" condition source. The id + /// is not a secret; the secret is only needed until a token is cached, and + /// `SLIDR_DISCORD_CLIENT_SECRET` overrides whatever is stored here. + #[serde(default)] + pub discord_client_id: String, + #[serde(default)] + pub discord_client_secret: String, + /// Last loaded profile name (sticky across launches). #[serde(default)] pub active_preset: String, @@ -484,6 +503,8 @@ impl Default for Settings { start_minimized: false, tray_intro_shown: false, led_experimental: false, + discord_client_id: String::new(), + discord_client_secret: String::new(), active_preset: "Default".into(), } } diff --git a/src/tray.rs b/src/tray.rs index 6937b70..c259375 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -20,21 +20,49 @@ pub struct Tray { toggle_id: tray_icon::menu::MenuId, exit_id: tray_icon::menu::MenuId, weak: slint::Weak, + /// Last label state pushed to the menu item, so the visibility poll only + /// touches the item when it actually changes. + shown_label: std::cell::Cell, } impl Tray { /// Reflect window visibility in the toggle label. /// hidden=true → "Show Slidr"; hidden=false → "Minimize Slidr". pub fn set_window_hidden(&self, hidden: bool) { + if self.shown_label.get() == !hidden { + return; + } + self.shown_label.set(!hidden); self.toggle .set_text(if hidden { "Show Slidr" } else { "Minimize Slidr" }); } + /// True when the window is actually on screen — visible *and* not minimized + /// to the taskbar. A minimized window is "not shown" for the toggle's + /// purposes: the next click should bring it back, not hide it further. + fn is_shown(&self) -> bool { + self.weak + .upgrade() + .map(|ui| ui.window().is_visible() && !ui.window().is_minimized()) + .unwrap_or(false) + } + + /// Re-derive the toggle label from the window's real state. Called every + /// poll tick so the label can't drift out of sync — whether the window was + /// never shown (autostart `--hidden`), minimized from the taskbar, or + /// restored by the user outside our own code paths. + fn sync_label(&self) { + self.set_window_hidden(!self.is_shown()); + } + fn show_window(&self) { if let Some(ui) = self.weak.upgrade() { let _ = ui.show(); + // Un-minimize covers the "sitting in the taskbar" case; `show()` + // alone is a no-op for a window that is visible but minimized. ui.window().set_minimized(false); } + raise_to_foreground(); self.set_window_hidden(false); } @@ -46,8 +74,7 @@ impl Tray { } fn toggle_window(&self) { - let visible = self.weak.upgrade().map(|ui| ui.window().is_visible()).unwrap_or(false); - if visible { + if self.is_shown() { self.hide_window(); } else { self.show_window(); @@ -55,6 +82,45 @@ impl Tray { } } +/// Bring this process's main window to the front. +/// +/// Slint has no "focus the window" API, and `set_minimized(false)` restores the +/// window without necessarily raising it above whatever the user was in. We +/// locate our own top-level window by process id and ask the shell directly. +fn raise_to_foreground() { + use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; + use windows::Win32::System::Threading::GetCurrentProcessId; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindow, GetWindowThreadProcessId, IsWindowVisible, SetForegroundWindow, + ShowWindow, GW_OWNER, SW_RESTORE, + }; + + unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL { + let out = &mut *(lparam.0 as *mut Option); + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + // Ours, top-level (no owner), and actually mapped — skips the hidden + // helper windows winit and the tray create alongside the real one. + if pid == GetCurrentProcessId() + && GetWindow(hwnd, GW_OWNER).is_err() + && IsWindowVisible(hwnd).as_bool() + { + *out = Some(hwnd); + return BOOL(0); // found it — stop enumerating + } + BOOL(1) + } + + unsafe { + let mut found: Option = None; + let _ = EnumWindows(Some(cb), LPARAM(&mut found as *mut _ as isize)); + if let Some(hwnd) = found { + let _ = ShowWindow(hwnd, SW_RESTORE); + let _ = SetForegroundWindow(hwnd); + } + } +} + fn load_icon() -> Option { let bytes = include_bytes!("../assets/logo.ico"); let img = image::load_from_memory(bytes).ok()?.into_rgba8(); @@ -63,10 +129,13 @@ fn load_icon() -> Option { } /// Build the tray and wire its events. Run on the UI thread. Returns the tray -/// (keep it alive). The toggle starts as "Minimize Slidr" (window visible). -pub fn install(ui: &AppWindow) -> Option> { +/// (keep it alive). `start_hidden` seeds the toggle label for a window that is +/// never shown (autostart-minimized); from then on the poll below keeps it in +/// sync with the window's real state. +pub fn install(ui: &AppWindow, start_hidden: bool) -> Option> { let menu = Menu::new(); - let toggle = MenuItem::new("Minimize Slidr", true, None); + let initial = if start_hidden { "Show Slidr" } else { "Minimize Slidr" }; + let toggle = MenuItem::new(initial, true, None); let exit = MenuItem::new("Exit", true, None); menu.append(&toggle).ok()?; menu.append(&exit).ok()?; @@ -87,6 +156,7 @@ pub fn install(ui: &AppWindow) -> Option> { toggle_id, exit_id, weak: ui.as_weak(), + shown_label: std::cell::Cell::new(!start_hidden), }); // Poll tray + menu events on the UI thread via a Slint timer. @@ -96,6 +166,10 @@ pub fn install(ui: &AppWindow) -> Option> { slint::TimerMode::Repeated, std::time::Duration::from_millis(120), move || { + // Keep the label honest even when the window state changed without + // going through us (minimize button, Win+D, restore from taskbar). + tray_for_timer.sync_label(); + if let Ok(ev) = MenuEvent::receiver().try_recv() { if ev.id == tray_for_timer.toggle_id { tray_for_timer.toggle_window(); diff --git a/ui/app.slint b/ui/app.slint index 036ef1c..24927bd 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -26,7 +26,10 @@ export struct ButtonCell { pressed: bool, category: string, assigned: bool } export struct LineItem { id: int, primary: string, secondary: string, icon-kind: int } export struct CategorySummary { id: int, name: string, count: int, collapsed: bool, lines: [LineItem] } export struct AssignPick { id: int, name: string } -export struct PickerEntry { name: string, icon-kind: int, selected: bool } +// A row in the wizard's picker. `name` is what the user reads; `value` is what +// gets stored (a process name, an executable path, or an "@group:" reference +// for a curated program category — `is-group` marks the latter). +export struct PickerEntry { name: string, value: string, icon-kind: int, selected: bool, is-group: bool } export struct WizardResult { target-kind: int, category-id: int, kind: int, property: string, display: string, @@ -35,6 +38,8 @@ export struct WizardResult { } // One LED toggle condition. Fields are interpreted by `kind`: // kind 0 = device muted · 1 = volume compare · 2 = API call +// 3 = media session playing · 4 = Discord voice state +// signal (kind 4) 0 = muted · 1 = deafened · 2 = in a voice channel // audio-kind 0 = process · 1 = microphone · 2 = output // cmp 0 = below · 1 = above · 2 = exactly · method 0 GET..4 DELETE export struct LedConditionUI { @@ -42,6 +47,7 @@ export struct LedConditionUI { cmp: int, value: int, method: int, url: string, payload: string, bearer: string, interval: string, expect-body: string, + signal: int, } // ────────────────────────────────────────────────────────────────────────── @@ -92,6 +98,7 @@ component KindIcon { : root.kind == 3 ? @image-url("../assets/icons/world.svg") : root.kind == 4 ? @image-url("../assets/icons/keyboard.svg") : root.kind == 5 ? @image-url("../assets/icons/volume.svg") + : root.kind == 6 ? @image-url("../assets/icons/grid-dots.svg") : @image-url("../assets/icons/app-window.svg"); } } @@ -1066,6 +1073,9 @@ component SettingsPage inherits VerticalBox { in-out property curve-x2; in-out property curve-y2; in-out property led-experimental; + in-out property discord-client-id; + in-out property discord-client-secret; + in property discord-status; in property build-stamp; callback theme-changed(int); @@ -1189,6 +1199,22 @@ component SettingsPage inherits VerticalBox { Toggle { checked <=> root.led-experimental; toggled => { root.dirty(); } } } } } + if root.led-experimental: SettingRow { + label: "Discord (LED source)"; + description: "Lets an LED follow your Discord mute. Create an application at discord.com/developers, add the redirect http://localhost, then paste its Client ID and Secret. Discord asks you to confirm once; the secret is only needed until then."; + VerticalLayout { width: root.control-w; spacing: 6px; + LineEdit { height: 32px; text <=> root.discord-client-id; + placeholder-text: "Client ID"; + edited(t) => { root.dirty(); } } + LineEdit { height: 32px; text <=> root.discord-client-secret; + input-type: password; + placeholder-text: "Client Secret"; + edited(t) => { root.dirty(); } } + if root.discord-status != "": Text { text: root.discord-status; + font-size: 11px; font-family: "Inter"; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.6); } + } + } if root.led-experimental: SettingRow { label: "Board memory"; description: "Save freezes the current look so it survives a restart; Clear turns all LEDs off and saves that."; @@ -1459,6 +1485,9 @@ component WizardPopup inherits Rectangle { in property <[string]> live-outputs; in-out property filter: ""; + // True while `property` holds a program-category reference rather than a + // concrete program. Drives the category chip in the "Open application" form. + in-out property picked-group: false; callback cancel(); callback finish(); @@ -1489,8 +1518,13 @@ component WizardPopup inherits Rectangle { // Key library uses the picker list too (kind 5); cycle-output (kind 6) is a // multi-select list, so its rows toggle membership. App/website (3/4) and the // API call use a form instead of a picker list. - property picker-is-list: !(root.target-kind == 1 && (root.kind == 3 || root.kind == 4)) + property picker-is-list: !(root.target-kind == 1 && root.kind == 4) && !root.is-api; + // "Open application" pairs the picker with a free-form path + Browse…; + // "Open website" has only the form. + property needs-path-form: root.target-kind == 1 + && (root.kind == 3 || root.kind == 4) + && !root.is-api; property multi-select: root.target-kind == 1 && root.kind == 6; Rectangle { @@ -1594,7 +1628,7 @@ component WizardPopup inherits Rectangle { // Single-select kinds highlight the chosen name; multi-select // (cycle output) tracks each row's `selected` flag instead. property row-selected: - root.multi-select ? e.selected : e.name == root.property; + root.multi-select ? e.selected : e.value == root.property; border-radius: 8px; background: row.row-selected ? Theme.accent-bg.with-alpha(0.22) @@ -1609,7 +1643,9 @@ component WizardPopup inherits Rectangle { if (root.multi-select) { root.toggle-pick(e.name); } else { - root.property = e.name; root.display = e.name; + root.property = e.value; + root.display = e.name; + root.picked-group = e.is-group; } } HorizontalLayout { padding-left: 12px; padding-right: 12px; @@ -1630,10 +1666,30 @@ component WizardPopup inherits Rectangle { } } - if !root.picker-is-list && !root.is-api: VerticalBox { + // A chosen category has no path to show — offer to clear it instead. + if root.needs-path-form && root.picked-group: HorizontalLayout { + spacing: 8px; + Rectangle { + border-radius: 8px; height: 36px; + background: Theme.accent-bg.with-alpha(0.22); + border-width: 1px; border-color: Theme.accent-bg; + horizontal-stretch: 1; + HorizontalLayout { padding-left: 12px; padding-right: 12px; spacing: 8px; + VerticalLayout { alignment: center; + KindIcon { kind: 6; size: 18px; } } + Text { text: "Category: " + root.display; vertical-alignment: center; + font-family: "Inter"; horizontal-stretch: 1; } + } + } + IconButton { label: "Clear"; + clicked => { root.property = ""; root.display = ""; + root.picked-group = false; } } + } + + if root.needs-path-form && !root.picked-group: VerticalBox { spacing: 6px; alignment: start; Text { - text: root.kind == 3 ? "Path or command to launch" + text: root.kind == 3 ? "…or pick a path / command to launch" : "URL (e.g. https://example.com)"; font-size: 12px; font-family: "Inter"; color: Palette.foreground.with-alpha(0.7); @@ -1865,6 +1921,10 @@ component LedConfigPopup inherits Rectangle { in-out property inactive-btarget; in property inactive-btidx; in property <[LedConditionUI]> conditions; + /// Media sources the OS has seen, shown as a hint under the media filter. + in property media-hint; + /// One-line status for the Discord source (configured / connected / why not). + in property discord-hint; in property <[string]> live-processes; in property <[string]> live-mics; in property <[string]> live-outputs; @@ -1877,6 +1937,8 @@ component LedConfigPopup inherits Rectangle { callback refresh(); callback manual-toggle(); callback cond-kind(int, int); + callback cond-media(int, string); + callback cond-signal(int, int); callback cond-audio(int, int); callback cond-target(int, string); callback cond-cmp(int, int); @@ -1990,7 +2052,8 @@ component LedConfigPopup inherits Rectangle { VerticalBox { padding: 12px; spacing: 8px; HorizontalLayout { spacing: 8px; Select { field-width: 170px; - model: ["Device muted", "Volume", "API call"]; + model: ["Device muted", "Volume", "API call", + "Music playing", "Discord"]; current-index: c.kind; selected(i) => { root.cond-kind(idx, i); } } Rectangle { horizontal-stretch: 1; } @@ -2019,6 +2082,26 @@ component LedConfigPopup inherits Rectangle { horizontal-alignment: right; vertical-alignment: center; font-family: "Inter"; } } + // Media session: a source filter, empty = any player. + if c.kind == 3: VerticalBox { spacing: 4px; + LineEdit { text: c.target; height: 34px; + placeholder-text: "Player (empty = any) — e.g. spotify"; + edited(t) => { root.cond-media(idx, t); } } + if root.media-hint != "": Text { text: root.media-hint; + font-size: 11px; font-family: "Inter"; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.6); } + } + // Discord voice state. + if c.kind == 4: VerticalBox { spacing: 4px; + Select { field-width: 250px; + model: ["Muted in Discord", "Deafened in Discord", + "In a voice channel"]; + current-index: c.signal; + selected(i) => { root.cond-signal(idx, i); } } + if root.discord-hint != "": Text { text: root.discord-hint; + font-size: 11px; font-family: "Inter"; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.6); } + } if c.kind == 2: VerticalBox { spacing: 6px; HorizontalLayout { spacing: 8px; Select { field-width: 110px; @@ -2107,6 +2190,9 @@ export component AppWindow inherits Window { in-out property accent-preset: 0; in-out property dead-zone; in-out property led-experimental; + in-out property discord-client-id; + in-out property discord-client-secret; + in property discord-status; in-out property unmute-on-change; in-out property start-with-os; in-out property start-minimized; @@ -2151,6 +2237,8 @@ export component AppWindow inherits Window { in-out property led-inactive-btarget: ""; in-out property led-inactive-btidx: -1; in property <[LedConditionUI]> led-conditions; + in property led-media-hint; + in property led-discord-hint; in-out property led-live-active; in-out property preset-popup-open: false; @@ -2166,6 +2254,7 @@ export component AppWindow inherits Window { in-out property wizard-property: ""; in-out property wizard-display: ""; in-out property wizard-filter: ""; + in-out property wizard-picked-group: false; in-out property wizard-method: 1; // default POST in-out property wizard-url: ""; in-out property wizard-payload: ""; @@ -2234,6 +2323,8 @@ export component AppWindow inherits Window { callback led-refresh(); callback led-manual-toggle(); callback led-cond-kind(int, int); + callback led-cond-media(int, string); + callback led-cond-signal(int, int); callback led-cond-audio(int, int); callback led-cond-target(int, string); callback led-cond-cmp(int, int); @@ -2445,6 +2536,9 @@ export component AppWindow inherits Window { accent-preset <=> root.accent-preset; dead-zone <=> root.dead-zone; led-experimental <=> root.led-experimental; + discord-client-id <=> root.discord-client-id; + discord-client-secret <=> root.discord-client-secret; + discord-status: root.discord-status; unmute-on-change <=> root.unmute-on-change; start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; @@ -2508,6 +2602,8 @@ export component AppWindow inherits Window { inactive-btarget <=> root.led-inactive-btarget; inactive-btidx: root.led-inactive-btidx; conditions: root.led-conditions; + media-hint: root.led-media-hint; + discord-hint: root.led-discord-hint; live-processes: root.live-processes; live-mics: root.live-mics; live-outputs: root.live-outputs; @@ -2519,6 +2615,8 @@ export component AppWindow inherits Window { refresh => { root.led-refresh(); } manual-toggle => { root.led-manual-toggle(); } cond-kind(i, v) => { root.led-cond-kind(i, v); } + cond-media(i, v) => { root.led-cond-media(i, v); } + cond-signal(i, v) => { root.led-cond-signal(i, v); } cond-audio(i, v) => { root.led-cond-audio(i, v); } cond-target(i, v) => { root.led-cond-target(i, v); } cond-cmp(i, v) => { root.led-cond-cmp(i, v); } @@ -2543,6 +2641,7 @@ export component AppWindow inherits Window { bearer <=> root.wizard-bearer; throttle <=> root.wizard-throttle; filter <=> root.wizard-filter; + picked-group <=> root.wizard-picked-group; picker-source: root.wizard-picker-source; live-processes: root.live-processes; live-mics: root.live-mics; @@ -2553,7 +2652,8 @@ export component AppWindow inherits Window { cancel => { root.wizard-open = false; root.wizard-step = 0; root.wizard-property = ""; root.wizard-display = ""; root.wizard-url = ""; root.wizard-payload = ""; root.wizard-bearer = ""; - root.wizard-throttle = 500; root.wizard-filter = ""; } + root.wizard-throttle = 500; root.wizard-filter = ""; + root.wizard-picked-group = false; } refresh => { root.refresh-live-lists(); root.wizard-state-changed(); } finish => { root.wizard-finish({ @@ -2577,6 +2677,7 @@ export component AppWindow inherits Window { root.wizard-bearer = ""; root.wizard-throttle = 500; root.wizard-filter = ""; + root.wizard-picked-group = false; } } From 4de4f97fb89c40b4e46ce3845227a60149151445 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 14:16:41 +0200 Subject: [PATCH 2/5] feat(led): media condition gains playback states; report why Discord fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media condition asked one question — "is something playing?" — which turns out to be the least useful of the four states a player can be in. It is now a "Media" condition with an explicit state: Playing, Paused, Stopped, or Nothing detected (no media session at all). The optional source filter stays. With several players open the liveliest state wins: a playing Spotify outranks a paused browser tab. Without that, "Playing" would stop meaning "music is on" as soon as any paused tab existed, and the four states would not be mutually exclusive. Discord, after watching someone try to use it: * The fields were findable only by knowing where to look, and every failure produced the same generic line. The status now names the actual cause — Discord not running, Client ID/Secret rejected (HTTP 401), or the missing http://localhost redirect (HTTP 400) — instead of "waiting for Discord". * That status is refreshed while the settings page or the LED popup is open. The worker connects seconds after the settings are saved, so the old push-once behaviour meant the line was stale exactly when it mattered. * Added an "Open developer portal" button next to the credentials, since the setup is a detour through a web page the app can at least open. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +-- src/discord.rs | 32 ++++++++- src/glue.rs | 69 +++++++++++++++++-- src/led.rs | 2 +- src/main.rs | 1 + src/media.rs | 179 ++++++++++++++++++++++++++++++++++++++----------- src/model.rs | 7 +- ui/app.slint | 26 +++++-- 8 files changed, 268 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index d3025a4..877eb2e 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,12 @@ and macro board. drift apart (per-app volumes otherwise follow the *new* endpoint's remembered levels). - **LED state sources** (experimental) — besides mute/volume/HTTP, an LED can - follow the OS media session ("music is playing", any player that publishes - transport controls) or Discord's voice state (self-mute / deafen / in a voice - channel). Discord needs a one-off OAuth application — see - [`src/discord.rs`](src/discord.rs) for the three setup steps. + follow the OS media session (playing / paused / stopped / nothing detected, + for any player that publishes transport controls, optionally filtered to one) + or Discord's voice state (self-mute / deafen / in a voice channel). Discord + needs a one-off OAuth application — see [`src/discord.rs`](src/discord.rs) for + the three setup steps, or the "Open developer portal" button under + Settings → LEDs. - **Hot-plug** — automatic Arduino detection, reconnect with backoff. ## Architecture diff --git a/src/discord.rs b/src/discord.rs index 8b45464..750bba0 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -89,6 +89,11 @@ impl DiscordState { } static STATE: OnceLock> = OnceLock::new(); +/// Why the last connection attempt failed, for the settings/LED status line. +/// Without this the UI can only say "not connected", which tells the user +/// nothing about whether Discord is closed, the id is wrong, or the redirect +/// URI is missing from their application. +static LAST_ERROR: OnceLock>> = OnceLock::new(); static CONFIG: OnceLock>> = OnceLock::new(); static RUNNING: AtomicBool = AtomicBool::new(false); /// Bumped whenever the credentials change, so an older worker retires itself. @@ -112,6 +117,15 @@ pub fn state() -> DiscordState { *state_cell().lock() } +fn error_cell() -> &'static Mutex> { + LAST_ERROR.get_or_init(|| Mutex::new(None)) +} + +/// Why the worker is not connected right now, if it has tried and failed. +pub fn last_error() -> Option { + error_cell().lock().clone() +} + /// Does this look like a Discord application id? They are snowflakes — a run of /// digits — and settings are saved on every keystroke, so a half-typed id must /// not send us off to open a socket and fail. @@ -140,6 +154,7 @@ pub fn configure(client_id: &str, client_secret: &str) { *slot = next.clone(); GENERATION.fetch_add(1, Ordering::SeqCst); *state_cell().lock() = DiscordState::default(); + *error_cell().lock() = None; drop(slot); if next.is_none() { @@ -164,7 +179,10 @@ fn worker() { }; match session(&creds, generation) { Ok(()) => log::info!("discord: session ended"), - Err(e) => log::warn!("discord: {e}"), + Err(e) => { + log::warn!("discord: {e}"); + *error_cell().lock() = Some(e.to_string()); + } } *state_cell().lock() = DiscordState::default(); std::thread::sleep(RECONNECT_DELAY); @@ -180,6 +198,7 @@ fn session(creds: &Credentials, generation: u64) -> anyhow::Result<()> { let token = authenticate(&mut conn, creds)?; save_token(&token); state_cell().lock().connected = true; + *error_cell().lock() = None; log::info!("discord: authenticated"); // Current values first, then the live updates. @@ -294,8 +313,8 @@ fn authenticate(conn: &mut Connection, creds: &Credentials) -> anyhow::Result anyhow::Result anyhow::anyhow!( + "Discord rejected the Client ID/Secret (HTTP 401) — check both in Settings" + ), + ureq::Error::Status(400, _) => anyhow::anyhow!( + "Discord rejected the authorisation (HTTP 400) — the application needs \ + the redirect http://localhost registered under OAuth2" + ), ureq::Error::Status(code, _) => { anyhow::anyhow!("token endpoint returned HTTP {code}") } diff --git a/src/glue.rs b/src/glue.rs index 7464d7e..546df8b 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -39,6 +39,8 @@ pub struct Shared { pub pending_retry_deadline: Option, /// When editing an existing category entry, the index being replaced. pub editing_idx: Option, + /// Throttle for the Discord/media status lines refreshed by the UI timer. + pub last_status_refresh: Option, /// Working copy for the configure-LED popup: (led index, config being edited). pub editing_led: Option<(usize, LedConfig)>, } @@ -493,6 +495,16 @@ pub fn wire( } // ─── settings ────────────────────────────────────────────────────────── + { + // Straight to the page where the Client ID lives — the setup is a + // detour through a web portal and the app should at least open the door. + ui.on_open_discord_portal(move || { + let url = "https://discord.com/developers/applications"; + if let Err(e) = open::that(url) { + log::warn!("could not open {url}: {e}"); + } + }); + } { let shared = shared.clone(); let weak = ui.as_weak(); @@ -644,6 +656,7 @@ pub fn wire( // stops it). Cheap and idempotent when nothing moved. crate::discord::configure(&s.settings.discord_client_id, &s.settings.discord_client_secret); ui.set_discord_status(discord_hint(&s.settings).into()); + ui.set_led_discord_hint(discord_hint(&s.settings).into()); }); } { @@ -897,6 +910,16 @@ pub fn wire( // Media source filter (free text; empty = any player). ui.on_led_cond_media(move |idx, v| { mutate_cond(&shared, idx, |c| c.target = opt(v.as_str())); }); } + { + let shared = shared.clone(); + let weak = ui.as_weak(); + ui.on_led_cond_media_state(move |idx, v| { + let conds = mutate_cond_struct(&shared, idx, |c| { + c.media = crate::media::MediaStatus::from_index(v) + }); + if let (Some(ui), Some(conds)) = (weak.upgrade(), conds) { push_led_conditions(&ui, &conds); } + }); + } { let shared = shared.clone(); let weak = ui.as_weak(); @@ -996,6 +1019,33 @@ pub fn wire( ui.set_led_live_active(active); } + // The Discord worker connects (or fails) seconds after the settings are + // saved, and the media poller updates once a second — so refresh both + // status lines while the user is looking at them, or they would sit on + // whatever was true when the page opened. Throttled to ~2/s, and the + // strings only reach Slint when they actually change. + if ui.get_current_page() == 3 || ui.get_led_popup_open() { + let due = s + .last_status_refresh + .map_or(true, |t: Instant| t.elapsed() >= Duration::from_millis(500)); + if due { + s.last_status_refresh = Some(Instant::now()); + let hint = discord_hint(&s.settings); + if ui.get_discord_status() != hint.as_str() { + ui.set_discord_status(hint.clone().into()); + } + if ui.get_led_popup_open() { + if ui.get_led_discord_hint() != hint.as_str() { + ui.set_led_discord_hint(hint.into()); + } + let media = media_hint(); + if ui.get_led_media_hint() != media.as_str() { + ui.set_led_media_hint(media.into()); + } + } + } + } + // Animate retry countdown even between events. if let Some(deadline) = s.pending_retry_deadline { let remaining = deadline.saturating_duration_since(Instant::now()).as_secs_f32(); @@ -1643,6 +1693,7 @@ fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { interval: c.api.interval_ms.to_string().into(), expect_body: c.api.expect_body.clone().unwrap_or_default().into(), signal: c.discord.to_index(), + media: c.media.to_index(), } } @@ -1651,23 +1702,29 @@ fn cond_to_ui(c: &LedCondition, target_index: i32) -> LedConditionUI { fn media_hint() -> String { let sources = crate::media::sources(); if sources.is_empty() { - "No media session detected yet — start playback, then Refresh.".into() + "No media session detected — nothing with transport controls is open.".into() } else { - format!("Detected: {}", sources.join(", ")) + let list: Vec = + sources.iter().map(|(id, st)| format!("{id} ({})", st.label())).collect(); + format!("Detected: {}", list.join(", ")) } } fn discord_hint(settings: &Settings) -> String { if settings.discord_client_id.trim().is_empty() { - return "Not set up — add a Discord application under Settings → LEDs.".into(); + return "Not set up — paste the Client ID of your Discord application below.".into(); } if !crate::discord::is_valid_id(&settings.discord_client_id) { return "That Client ID doesn't look right — it is a long number from the developer portal.".into(); } if crate::discord::state().connected { - "Connected to Discord.".into() - } else { - "Waiting for Discord — is it running, and did you confirm the authorisation prompt?".into() + return "Connected to Discord.".into(); + } + // Say *why* it isn't connected. The worker retries every 10s, so a stale + // reason still describes the situation. + match crate::discord::last_error() { + Some(e) => format!("Not connected: {e}"), + None => "Connecting to Discord…".into(), } } diff --git a/src/led.rs b/src/led.rs index 524ebc6..afd0822 100644 --- a/src/led.rs +++ b/src/led.rs @@ -254,7 +254,7 @@ impl LedEngine { // Both of these are served from a cache kept by a background // worker, so the tick never waits on WinRT or a socket. LedConditionKind::Media => { - crate::media::state().is_playing(cond.target.as_deref()) + crate::media::state().status(cond.target.as_deref()) == cond.media } LedConditionKind::Discord => crate::discord::state().get(cond.discord), } diff --git a/src/main.rs b/src/main.rs index 7b0d5c8..3989ffa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,6 +139,7 @@ fn main() -> anyhow::Result<()> { pending_wizard: None, pending_retry_deadline: None, editing_idx: None, + last_status_refresh: None, editing_led: None, })); diff --git a/src/media.rs b/src/media.rs index 70f0f36..0514953 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,4 +1,4 @@ -//! OS media-session state ("is music playing?") as an LED condition source. +//! OS media-session state as an LED condition source. //! //! Windows exposes every app that publishes transport controls — Spotify, a //! browser tab, a desktop player — through the System Media Transport Controls @@ -7,35 +7,91 @@ //! //! The WinRT calls block on an async operation, so they run on a dedicated //! poller thread and everything else reads the cached snapshot. Non-Windows -//! builds report "nothing playing" (the MPRIS equivalent would go here). +//! builds report "nothing" (the MPRIS equivalent would go here). use std::sync::OnceLock; use std::time::Duration; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; /// How often the media session list is re-read. const POLL: Duration = Duration::from_millis(1000); +/// Playback state of a media source, as an LED condition asks about it. The four +/// are mutually exclusive, so a condition is a plain equality test. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MediaStatus { + #[default] + Playing, + Paused, + /// A player is open but neither playing nor paused — stopped, or between + /// tracks while it loads the next one. + Stopped, + /// No media session at all: nothing is even open. + Nothing, +} + +impl MediaStatus { + pub fn from_index(i: i32) -> Self { + match i { + 1 => Self::Paused, + 2 => Self::Stopped, + 3 => Self::Nothing, + _ => Self::Playing, + } + } + pub fn to_index(self) -> i32 { + match self { + Self::Playing => 0, + Self::Paused => 1, + Self::Stopped => 2, + Self::Nothing => 3, + } + } + pub fn label(self) -> &'static str { + match self { + Self::Playing => "playing", + Self::Paused => "paused", + Self::Stopped => "stopped", + Self::Nothing => "nothing", + } + } +} + #[derive(Debug, Clone, Default)] pub struct MediaState { - /// Source ids (Windows: the app user-model id) that are playing right now. - pub playing: Vec, - /// All known media sources, playing or not — used to populate the picker. - pub sources: Vec, + /// Every media session the OS reports, as (source id, status). + pub sessions: Vec<(String, MediaStatus)>, } impl MediaState { - /// Is anything playing? With `filter` set, only that source counts - /// (case-insensitive substring, so "spotify" matches "Spotify.exe"). - pub fn is_playing(&self, filter: Option<&str>) -> bool { - match filter.map(str::trim).filter(|f| !f.is_empty()) { - None => !self.playing.is_empty(), - Some(f) => { - let f = f.to_lowercase(); - self.playing.iter().any(|s| s.to_lowercase().contains(&f)) + /// The single status a condition compares against. With several players the + /// liveliest one wins — something actually playing outranks something merely + /// paused — so "Playing" means "music is on" regardless of what else is + /// open. With `filter` set only matching sources count (case-insensitive + /// substring, so "spotify" matches "Spotify.exe"); if none match, the answer + /// is `Nothing`. + pub fn status(&self, filter: Option<&str>) -> MediaStatus { + let filter = filter + .map(str::trim) + .filter(|f| !f.is_empty()) + .map(str::to_lowercase); + let mut best: Option = None; + for (id, st) in &self.sessions { + if let Some(f) = &filter { + if !id.to_lowercase().contains(f.as_str()) { + continue; + } } + best = Some(match (best, *st) { + (Some(MediaStatus::Playing), _) | (_, MediaStatus::Playing) => MediaStatus::Playing, + (Some(MediaStatus::Paused), _) | (_, MediaStatus::Paused) => MediaStatus::Paused, + _ => MediaStatus::Stopped, + }); } + best.unwrap_or(MediaStatus::Nothing) } } @@ -51,9 +107,9 @@ pub fn state() -> MediaState { cell.lock().clone() } -/// Media sources the OS knows about, for the condition's target picker. -pub fn sources() -> Vec { - state().sources +/// Media sources the OS knows about with their status, for the UI hint. +pub fn sources() -> Vec<(String, MediaStatus)> { + state().sessions } fn start_poller() { @@ -104,16 +160,23 @@ fn read_sessions() -> MediaState { if id.is_empty() { continue; } - if !out.sources.contains(&id) { - out.sources.push(id.clone()); - } - let playing = session - .GetPlaybackInfo() - .and_then(|info| info.PlaybackStatus()) - .map(|status| status == PlaybackStatus::Playing) - .unwrap_or(false); - if playing && !out.playing.contains(&id) { - out.playing.push(id); + let status = match session.GetPlaybackInfo().and_then(|info| info.PlaybackStatus()) { + Ok(PlaybackStatus::Playing) => MediaStatus::Playing, + Ok(PlaybackStatus::Paused) => MediaStatus::Paused, + // Stopped / Opened / Changing all mean "a player is there but no + // sound is coming out"; Closed sessions are not enumerated. + Ok(_) => MediaStatus::Stopped, + Err(_) => continue, + }; + // One entry per source — a player can register more than one session, + // and the livelier one is the interesting one. + match out.sessions.iter_mut().find(|(known, _)| *known == id) { + Some(slot) => { + if status == MediaStatus::Playing { + slot.1 = status; + } + } + None => out.sessions.push((id, status)), } } out @@ -122,7 +185,7 @@ fn read_sessions() -> MediaState { #[cfg(not(target_os = "windows"))] fn read_sessions() -> MediaState { // Linux would read MPRIS over D-Bus (org.mpris.MediaPlayer2.*); Slidr has no - // D-Bus dependency today, so the condition simply never fires there. + // D-Bus dependency today, so the condition reports "nothing" there. MediaState::default() } @@ -130,16 +193,56 @@ fn read_sessions() -> MediaState { mod tests { use super::*; + fn state_of(v: &[(&str, MediaStatus)]) -> MediaState { + MediaState { sessions: v.iter().map(|(a, b)| (a.to_string(), *b)).collect() } + } + #[test] - fn filter_matching() { - let s = MediaState { - playing: vec!["Spotify.exe".into()], - sources: vec!["Spotify.exe".into(), "firefox.exe".into()], - }; - assert!(s.is_playing(None)); - assert!(s.is_playing(Some("spotify"))); - assert!(s.is_playing(Some(" ")), "blank filter means any source"); - assert!(!s.is_playing(Some("firefox"))); - assert!(!MediaState::default().is_playing(None)); + fn nothing_without_a_matching_session() { + assert_eq!(MediaState::default().status(None), MediaStatus::Nothing); + let s = state_of(&[("Spotify.exe", MediaStatus::Playing)]); + assert_eq!(s.status(Some("vlc")), MediaStatus::Nothing); + } + + #[test] + fn filter_is_a_case_insensitive_substring() { + let s = state_of(&[ + ("Spotify.exe", MediaStatus::Playing), + ("firefox.exe", MediaStatus::Paused), + ]); + assert_eq!(s.status(Some("spotify")), MediaStatus::Playing); + assert_eq!(s.status(Some("firefox")), MediaStatus::Paused); + assert_eq!(s.status(Some(" ")), MediaStatus::Playing, "blank filter = any source"); + } + + #[test] + fn the_liveliest_source_wins() { + // A paused browser tab next to a playing player still means "playing". + let s = state_of(&[ + ("firefox.exe", MediaStatus::Paused), + ("Spotify.exe", MediaStatus::Playing), + ]); + assert_eq!(s.status(None), MediaStatus::Playing); + + let s = state_of(&[ + ("firefox.exe", MediaStatus::Stopped), + ("Spotify.exe", MediaStatus::Paused), + ]); + assert_eq!(s.status(None), MediaStatus::Paused); + + let s = state_of(&[("firefox.exe", MediaStatus::Stopped)]); + assert_eq!(s.status(None), MediaStatus::Stopped); + } + + #[test] + fn status_index_round_trip() { + for st in [ + MediaStatus::Playing, + MediaStatus::Paused, + MediaStatus::Stopped, + MediaStatus::Nothing, + ] { + assert_eq!(MediaStatus::from_index(st.to_index()), st); + } } } diff --git a/src/model.rs b/src/model.rs index d4b3887..62e58ac 100644 --- a/src/model.rs +++ b/src/model.rs @@ -312,8 +312,8 @@ pub enum LedConditionKind { Muted, Volume, Api, - /// Something is playing through the OS media session (Spotify, a browser - /// tab, any player that publishes transport controls). + /// The OS media session's playback state (Spotify, a browser tab, any + /// player that publishes transport controls). Media, /// Discord voice state (self-mute / deafen / in a voice channel). Discord, @@ -383,6 +383,9 @@ pub struct LedCondition { /// For `Discord`: which part of the voice state to follow. #[serde(default)] pub discord: crate::discord::DiscordSignal, + /// For `Media`: the playback state to match. + #[serde(default)] + pub media: crate::media::MediaStatus, } /// Per-LED configuration. Conditions decide the `active` vs `inactive` look. diff --git a/ui/app.slint b/ui/app.slint index 24927bd..d870a32 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -38,7 +38,8 @@ export struct WizardResult { } // One LED toggle condition. Fields are interpreted by `kind`: // kind 0 = device muted · 1 = volume compare · 2 = API call -// 3 = media session playing · 4 = Discord voice state +// 3 = media session · 4 = Discord voice state +// media (kind 3) 0 = playing · 1 = paused · 2 = stopped · 3 = nothing // signal (kind 4) 0 = muted · 1 = deafened · 2 = in a voice channel // audio-kind 0 = process · 1 = microphone · 2 = output // cmp 0 = below · 1 = above · 2 = exactly · method 0 GET..4 DELETE @@ -47,7 +48,7 @@ export struct LedConditionUI { cmp: int, value: int, method: int, url: string, payload: string, bearer: string, interval: string, expect-body: string, - signal: int, + signal: int, media: int, } // ────────────────────────────────────────────────────────────────────────── @@ -1084,6 +1085,7 @@ component SettingsPage inherits VerticalBox { callback curve-changed(); callback led-save-state(); callback led-clear-state(); + callback open-discord-portal(); spacing: 14px; @@ -1210,6 +1212,11 @@ component SettingsPage inherits VerticalBox { input-type: password; placeholder-text: "Client Secret"; edited(t) => { root.dirty(); } } + HorizontalLayout { spacing: 6px; alignment: start; + IconButton { label: "Open developer portal"; + icon: @image-url("../assets/icons/world.svg"); + clicked => { root.open-discord-portal(); } } + } if root.discord-status != "": Text { text: root.discord-status; font-size: 11px; font-family: "Inter"; wrap: word-wrap; color: Palette.foreground.with-alpha(0.6); } @@ -1938,6 +1945,7 @@ component LedConfigPopup inherits Rectangle { callback manual-toggle(); callback cond-kind(int, int); callback cond-media(int, string); + callback cond-media-state(int, int); callback cond-signal(int, int); callback cond-audio(int, int); callback cond-target(int, string); @@ -2053,7 +2061,7 @@ component LedConfigPopup inherits Rectangle { HorizontalLayout { spacing: 8px; Select { field-width: 170px; model: ["Device muted", "Volume", "API call", - "Music playing", "Discord"]; + "Media", "Discord"]; current-index: c.kind; selected(i) => { root.cond-kind(idx, i); } } Rectangle { horizontal-stretch: 1; } @@ -2082,8 +2090,14 @@ component LedConfigPopup inherits Rectangle { horizontal-alignment: right; vertical-alignment: center; font-family: "Inter"; } } - // Media session: a source filter, empty = any player. + // Media session: which playback state, and from + // which player (empty filter = any player). if c.kind == 3: VerticalBox { spacing: 4px; + Select { field-width: 250px; + model: ["Playing", "Paused", "Stopped", + "Nothing detected"]; + current-index: c.media; + selected(i) => { root.cond-media-state(idx, i); } } LineEdit { text: c.target; height: 34px; placeholder-text: "Player (empty = any) — e.g. spotify"; edited(t) => { root.cond-media(idx, t); } } @@ -2313,6 +2327,7 @@ export component AppWindow inherits Window { // ── LED state persistence (board EEPROM) ── callback led-save-state(); callback led-clear-state(); + callback open-discord-portal(); // ── Configure-LED ── callback open-led-config(int); // button index @@ -2324,6 +2339,7 @@ export component AppWindow inherits Window { callback led-manual-toggle(); callback led-cond-kind(int, int); callback led-cond-media(int, string); + callback led-cond-media-state(int, int); callback led-cond-signal(int, int); callback led-cond-audio(int, int); callback led-cond-target(int, string); @@ -2557,6 +2573,7 @@ export component AppWindow inherits Window { curve-changed => { root.curve-changed(); } led-save-state => { root.led-save-state(); } led-clear-state => { root.led-clear-state(); } + open-discord-portal => { root.open-discord-portal(); } } } } @@ -2616,6 +2633,7 @@ export component AppWindow inherits Window { manual-toggle => { root.led-manual-toggle(); } cond-kind(i, v) => { root.led-cond-kind(i, v); } cond-media(i, v) => { root.led-cond-media(i, v); } + cond-media-state(i, v) => { root.led-cond-media-state(i, v); } cond-signal(i, v) => { root.led-cond-signal(i, v); } cond-audio(i, v) => { root.led-cond-audio(i, v); } cond-target(i, v) => { root.led-cond-target(i, v); } From 138e58a3be19bea60b5f3745ba5148bf47cbcbde Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 16:13:30 +0200 Subject: [PATCH 3/5] fix(ui): stop the settings page overflowing; add a Discord setup checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A setting row's label and description did not wrap. A Text without `wrap` reports its full single-line width as preferred, and inside a ScrollView that wins — so the long Discord description added in the previous commit dragged every card to the right and pushed the controls out of view entirely. Two changes, because either alone leaves the failure mode reachable: * the row's texts now wrap, and * the settings content is pinned to the viewport width, so this page can only ever scroll vertically no matter what text ends up in it. The Discord instructions move out of that description into a checklist under the credentials: the three steps, a button to the developer portal, and the redirect URL with a Copy button beside it. Forgetting the redirect is the usual reason the connection fails, and asking someone to retype a URL character for character was never going to work. Copying needs a clipboard, which Slint does not expose, so src/clipboard.rs talks to the platform: Win32 on Windows, and wl-copy/xclip/xsel on Linux. No new crate — just two more features on the `windows` dependency. The Win32 path is covered by a round-trip test (including non-ASCII, to catch a byte-truncating UTF-16 conversion); it restores whatever was on the clipboard. Verified by screenshotting the running window: no horizontal scrollbar, cards inside the viewport, checklist and controls visible. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 3 ++ src/clipboard.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++++ src/discord.rs | 4 +- src/glue.rs | 16 ++++++ src/main.rs | 1 + ui/app.slint | 85 ++++++++++++++++++++++++++--- 6 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 src/clipboard.rs diff --git a/Cargo.toml b/Cargo.toml index 289e045..4146b31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,9 @@ windows = { version = "0.58", features = [ "Win32_System_Diagnostics_ToolHelp", # Restoring/raising our own window from the tray (EnumWindows + SetForegroundWindow). "Win32_UI_WindowsAndMessaging", + # Clipboard for the "copy this value" buttons (src/clipboard.rs). + "Win32_System_DataExchange", + "Win32_System_Memory", # OS media session (SMTC) as an LED state source — "music is playing". "Media_Control", "Foundation", diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..229577d --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,138 @@ +//! Put a short piece of text on the system clipboard. +//! +//! Slint exposes no clipboard API, so this talks to the platform directly. Only +//! used for the small "copy this value" buttons in the UI, so it deliberately +//! stays dependency-free: Win32 on Windows, and whichever of the usual +//! command-line helpers exists on Linux. + +/// Copy `text` to the clipboard. +pub fn set_text(text: &str) -> anyhow::Result<()> { + set_text_impl(text) +} + +#[cfg(target_os = "windows")] +fn set_text_impl(text: &str) -> anyhow::Result<()> { + use windows::Win32::Foundation::{HANDLE, HWND}; + use windows::Win32::System::DataExchange::{ + CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData, + }; + use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; + + /// `CF_UNICODETEXT`. Spelled out rather than pulled from the OLE bindings so + /// this needs no extra crate feature. + const CF_UNICODETEXT: u32 = 13; + + // NUL-terminated UTF-16, which is what CF_UNICODETEXT expects. + let wide: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let bytes = std::mem::size_of_val(wide.as_slice()); + + unsafe { + OpenClipboard(HWND::default()).map_err(|e| anyhow::anyhow!("OpenClipboard: {e}"))?; + // From here on every path must close the clipboard, so the work happens + // in a closure and the handle is released once afterwards. + let result = (|| -> anyhow::Result<()> { + EmptyClipboard().map_err(|e| anyhow::anyhow!("EmptyClipboard: {e}"))?; + let mem = GlobalAlloc(GMEM_MOVEABLE, bytes) + .map_err(|e| anyhow::anyhow!("GlobalAlloc: {e}"))?; + let dst = GlobalLock(mem); + if dst.is_null() { + anyhow::bail!("GlobalLock returned null"); + } + std::ptr::copy_nonoverlapping(wide.as_ptr(), dst as *mut u16, wide.len()); + let _ = GlobalUnlock(mem); + // Ownership of `mem` transfers to the clipboard on success; on + // failure it leaks one small block, which beats a double free. + SetClipboardData(CF_UNICODETEXT, HANDLE(mem.0)) + .map_err(|e| anyhow::anyhow!("SetClipboardData: {e}"))?; + Ok(()) + })(); + let _ = CloseClipboard(); + result + } +} + +#[cfg(not(target_os = "windows"))] +fn set_text_impl(text: &str) -> anyhow::Result<()> { + use std::io::Write; + use std::process::{Command, Stdio}; + + // Wayland first, then the two X11 helpers. Whichever is installed wins. + let candidates: [(&str, &[&str]); 3] = [ + ("wl-copy", &[]), + ("xclip", &["-selection", "clipboard"]), + ("xsel", &["--clipboard", "--input"]), + ]; + for (bin, args) in candidates { + let child = Command::new(bin) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + let Ok(mut child) = child else { continue }; + if let Some(stdin) = child.stdin.as_mut() { + // No trailing newline — the value is pasted into a form field. + let _ = stdin.write_all(text.as_bytes()); + } + drop(child.stdin.take()); + if child.wait().map(|s| s.success()).unwrap_or(false) { + return Ok(()); + } + } + anyhow::bail!("no clipboard helper found (install wl-clipboard, xclip or xsel)") +} + +/// Read the clipboard back. Test-only: the app never needs to read, and adding +/// it to the public surface would invite use it doesn't have. +#[cfg(all(test, target_os = "windows"))] +fn get_text() -> Option { + use windows::Win32::Foundation::{HGLOBAL, HWND}; + use windows::Win32::System::DataExchange::{CloseClipboard, GetClipboardData, OpenClipboard}; + use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock}; + + const CF_UNICODETEXT: u32 = 13; + unsafe { + OpenClipboard(HWND::default()).ok()?; + let out = GetClipboardData(CF_UNICODETEXT).ok().and_then(|h| { + let mem = HGLOBAL(h.0); + let p = GlobalLock(mem) as *const u16; + if p.is_null() { + return None; + } + let mut len = 0usize; + while *p.add(len) != 0 { + len += 1; + } + let s = String::from_utf16_lossy(std::slice::from_raw_parts(p, len)); + let _ = GlobalUnlock(mem); + Some(s) + }); + let _ = CloseClipboard(); + out + } +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + + /// Round-trips through the real clipboard, because that is the only thing + /// worth asserting here — the Win32 dance either hands the OS a valid + /// CF_UNICODETEXT block or it silently does nothing, and the UI button + /// cannot tell the difference. Whatever the user had is put back. + #[test] + fn round_trips_through_the_system_clipboard() { + let previous = get_text(); + + set_text("http://localhost").expect("set_text should succeed"); + assert_eq!(get_text().as_deref(), Some("http://localhost")); + + // Non-ASCII, to prove the UTF-16 conversion is not byte-truncating. + set_text("Grüße — ok").expect("set_text should succeed"); + assert_eq!(get_text().as_deref(), Some("Grüße — ok")); + + if let Some(prev) = previous { + let _ = set_text(&prev); + } + } +} diff --git a/src/discord.rs b/src/discord.rs index 750bba0..182c698 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -30,7 +30,9 @@ use serde_json::{json, Value}; /// Scopes we need: `rpc` to talk to the client at all, `rpc.voice.read` for the /// voice settings. Both are read-only. const SCOPES: [&str; 2] = ["rpc", "rpc.voice.read"]; -const REDIRECT_URI: &str = "http://localhost"; +/// Redirect the user registers on their application, and which the token +/// exchange must repeat verbatim. Shown in the settings checklist. +pub const REDIRECT_URI: &str = "http://localhost"; const TOKEN_URL: &str = "https://discord.com/api/v10/oauth2/token"; /// Wait between reconnect attempts when Discord isn't running (or rejected us). diff --git a/src/glue.rs b/src/glue.rs index 546df8b..b7df352 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -495,6 +495,21 @@ pub fn wire( } // ─── settings ────────────────────────────────────────────────────────── + { + let weak = ui.as_weak(); + // The redirect has to match character for character, so hand it over + // rather than asking anyone to retype it. + ui.on_copy_discord_redirect(move || { + let Some(ui) = weak.upgrade() else { return }; + match crate::clipboard::set_text(crate::discord::REDIRECT_URI) { + Ok(()) => toast(&ui, "Copied http://localhost"), + Err(e) => { + log::warn!("clipboard: {e}"); + toast(&ui, "Could not copy — select the URL and copy it manually"); + } + } + }); + } { // Straight to the page where the Client ID lives — the setup is a // detour through a web portal and the app should at least open the door. @@ -1272,6 +1287,7 @@ fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model ui.set_discord_client_id(global.discord_client_id.clone().into()); ui.set_discord_client_secret(global.discord_client_secret.clone().into()); ui.set_discord_status(discord_hint(global).into()); + ui.set_discord_redirect(crate::discord::REDIRECT_URI.into()); ui.set_active_preset_name(global.active_preset.clone().into()); // Appearance + sliders (profile) ui.set_theme_index(match profile.theme { diff --git a/src/main.rs b/src/main.rs index 3989ffa..c12b13d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod actuator; mod app_groups; mod audio; mod autostart; +mod clipboard; mod curve; mod discord; mod events; diff --git a/ui/app.slint b/ui/app.slint index d870a32..4b8d443 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -590,9 +590,9 @@ component SettingRow inherits HorizontalLayout { VerticalLayout { horizontal-stretch: 1; spacing: 2px; - Text { text: root.label; vertical-alignment: center; + Text { text: root.label; vertical-alignment: center; wrap: word-wrap; font-family: "Inter"; font-size: 13px; font-weight: 500; } - if root.description != "": Text { text: root.description; + if root.description != "": Text { text: root.description; wrap: word-wrap; font-family: "Inter"; font-size: 11px; color: Palette.foreground.with-alpha(0.55); } } @@ -1077,6 +1077,7 @@ component SettingsPage inherits VerticalBox { in-out property discord-client-id; in-out property discord-client-secret; in property discord-status; + in property discord-redirect; in property build-stamp; callback theme-changed(int); @@ -1086,6 +1087,7 @@ component SettingsPage inherits VerticalBox { callback led-save-state(); callback led-clear-state(); callback open-discord-portal(); + callback copy-discord-redirect(); spacing: 14px; @@ -1102,6 +1104,10 @@ component SettingsPage inherits VerticalBox { vertical-stretch: 1; VerticalBox { spacing: 12px; alignment: start; + // Pin the content to the viewport width so this page scrolls only + // vertically. Left to itself the layout takes its preferred width, + // and one long description then drags every card off to the right. + width: parent.width - 18px; Card { VerticalBox { padding: 18px; spacing: 6px; @@ -1203,7 +1209,7 @@ component SettingsPage inherits VerticalBox { } if root.led-experimental: SettingRow { label: "Discord (LED source)"; - description: "Lets an LED follow your Discord mute. Create an application at discord.com/developers, add the redirect http://localhost, then paste its Client ID and Secret. Discord asks you to confirm once; the secret is only needed until then."; + description: "Let an LED follow your Discord mute"; VerticalLayout { width: root.control-w; spacing: 6px; LineEdit { height: 32px; text <=> root.discord-client-id; placeholder-text: "Client ID"; @@ -1212,16 +1218,75 @@ component SettingsPage inherits VerticalBox { input-type: password; placeholder-text: "Client Secret"; edited(t) => { root.dirty(); } } - HorizontalLayout { spacing: 6px; alignment: start; - IconButton { label: "Open developer portal"; - icon: @image-url("../assets/icons/world.svg"); - clicked => { root.open-discord-portal(); } } - } if root.discord-status != "": Text { text: root.discord-status; font-size: 11px; font-family: "Inter"; wrap: word-wrap; color: Palette.foreground.with-alpha(0.6); } } } + + // Setup checklist. Discord gates the voice-state scopes on an + // OAuth application the user has to create once, so the least + // we can do is name the three steps and hand over the exact + // redirect URL -- forgetting that one is the usual failure. + if root.led-experimental: Rectangle { + border-radius: 10px; border-width: 1px; + border-color: Palette.border; + background: Palette.alternate-background; + VerticalLayout { + padding: 12px; spacing: 8px; + Text { text: "One-time setup"; font-weight: 600; + font-family: "Inter"; font-size: 12px; } + + HorizontalLayout { spacing: 8px; + Text { text: "1."; font-family: "Inter"; font-size: 11px; + min-width: 14px; vertical-alignment: center; + color: Palette.foreground.with-alpha(0.55); } + Text { text: "Create an application in the Discord developer portal."; + font-family: "Inter"; font-size: 11px; wrap: word-wrap; + horizontal-stretch: 1; vertical-alignment: center; + color: Palette.foreground.with-alpha(0.75); } + IconButton { label: "Open portal"; + icon: @image-url("../assets/icons/world.svg"); + clicked => { root.open-discord-portal(); } } + } + + HorizontalLayout { spacing: 8px; + Text { text: "2."; font-family: "Inter"; font-size: 11px; + min-width: 14px; vertical-alignment: center; + color: Palette.foreground.with-alpha(0.55); } + VerticalLayout { spacing: 4px; horizontal-stretch: 1; + Text { text: "In OAuth2 > Redirects, add exactly this URL and save:"; + font-family: "Inter"; font-size: 11px; wrap: word-wrap; + color: Palette.foreground.with-alpha(0.75); } + HorizontalLayout { spacing: 6px; + Rectangle { + height: 30px; horizontal-stretch: 1; + border-radius: 6px; border-width: 1px; + border-color: Palette.border; + background: Palette.background; + HorizontalLayout { padding-left: 8px; padding-right: 8px; + Text { text: root.discord-redirect; + font-size: 12px; overflow: elide; + vertical-alignment: center; } + } + } + IconButton { label: "Copy"; + clicked => { root.copy-discord-redirect(); } } + } + } + } + + HorizontalLayout { spacing: 8px; + Text { text: "3."; font-family: "Inter"; font-size: 11px; + min-width: 14px; vertical-alignment: center; + color: Palette.foreground.with-alpha(0.55); } + Text { text: "Paste the Client ID and Client Secret above. Discord asks you to confirm once; after that the secret is no longer read."; + font-family: "Inter"; font-size: 11px; wrap: word-wrap; + horizontal-stretch: 1; + color: Palette.foreground.with-alpha(0.75); } + } + } + } if root.led-experimental: SettingRow { label: "Board memory"; description: "Save freezes the current look so it survives a restart; Clear turns all LEDs off and saves that."; @@ -2207,6 +2272,7 @@ export component AppWindow inherits Window { in-out property discord-client-id; in-out property discord-client-secret; in property discord-status; + in property discord-redirect; in-out property unmute-on-change; in-out property start-with-os; in-out property start-minimized; @@ -2328,6 +2394,7 @@ export component AppWindow inherits Window { callback led-save-state(); callback led-clear-state(); callback open-discord-portal(); + callback copy-discord-redirect(); // ── Configure-LED ── callback open-led-config(int); // button index @@ -2555,6 +2622,7 @@ export component AppWindow inherits Window { discord-client-id <=> root.discord-client-id; discord-client-secret <=> root.discord-client-secret; discord-status: root.discord-status; + discord-redirect: root.discord-redirect; unmute-on-change <=> root.unmute-on-change; start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; @@ -2574,6 +2642,7 @@ export component AppWindow inherits Window { led-save-state => { root.led-save-state(); } led-clear-state => { root.led-clear-state(); } open-discord-portal => { root.open-discord-portal(); } + copy-discord-redirect => { root.copy-discord-redirect(); } } } } From f343f08724c31c42c57e01bc7093ebb514d4cba4 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 16:24:03 +0200 Subject: [PATCH 4/5] fix(tray): drain the event queue; left click opens the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray timer took a single event per 120ms tick. `tray-icon` posts a Move event for every mouse movement across the icon, so a click sat behind a flood of them: it was handled seconds later, and further queued clicks then arrived one per tick — which is why the window appeared to open itself over and over and flashed in the taskbar. Both queues are now drained each tick and a burst collapses into one action. Left click opens the window (`with_menu_on_left_click(false)`), right click keeps the existing menu. Double click still works, but nothing depends on it any more. Co-Authored-By: Claude Opus 5 (1M context) --- src/tray.rs | 58 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/src/tray.rs b/src/tray.rs index c259375..3530fdc 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -10,7 +10,7 @@ use std::rc::Rc; use slint::ComponentHandle; use tray_icon::menu::{Menu, MenuEvent, MenuItem}; -use tray_icon::{TrayIcon, TrayIconBuilder, TrayIconEvent}; +use tray_icon::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use crate::AppWindow; @@ -129,9 +129,12 @@ fn load_icon() -> Option { } /// Build the tray and wire its events. Run on the UI thread. Returns the tray -/// (keep it alive). `start_hidden` seeds the toggle label for a window that is -/// never shown (autostart-minimized); from then on the poll below keeps it in -/// sync with the window's real state. +/// (keep it alive). +/// +/// Left click opens the window, right click opens the menu. `start_hidden` +/// seeds the toggle label for a window that is never shown +/// (autostart-minimized); from then on the poll below keeps it in sync with the +/// window's real state. pub fn install(ui: &AppWindow, start_hidden: bool) -> Option> { let menu = Menu::new(); let initial = if start_hidden { "Show Slidr" } else { "Minimize Slidr" }; @@ -144,6 +147,9 @@ pub fn install(ui: &AppWindow, start_hidden: bool) -> Option> { let mut builder = TrayIconBuilder::new() .with_menu(Box::new(menu)) + // Left click belongs to "show the window" (handled below); the menu is + // what right click is for. + .with_menu_on_left_click(false) .with_tooltip("Slidr"); if let Some(icon) = load_icon() { builder = builder.with_icon(icon); @@ -164,24 +170,50 @@ pub fn install(ui: &AppWindow, start_hidden: bool) -> Option> { let timer = slint::Timer::default(); timer.start( slint::TimerMode::Repeated, - std::time::Duration::from_millis(120), + std::time::Duration::from_millis(100), move || { // Keep the label honest even when the window state changed without // going through us (minimize button, Win+D, restore from taskbar). tray_for_timer.sync_label(); - if let Ok(ev) = MenuEvent::receiver().try_recv() { - if ev.id == tray_for_timer.toggle_id { - tray_for_timer.toggle_window(); - } else if ev.id == tray_for_timer.exit_id { - let _ = slint::quit_event_loop(); + // Drain both queues completely, then act once. + // + // `tray-icon` posts a Move event for every mouse movement across the + // icon, so taking a single event per tick left a click queued behind + // a flood of them: it arrived seconds late, and a second click + // arrived a tick after that, making the window pop to the front + // repeatedly. Collapsing a burst into one action fixes both. + let mut exit = false; + let mut toggle = false; + let mut show = false; + while let Ok(ev) = MenuEvent::receiver().try_recv() { + if ev.id == tray_for_timer.exit_id { + exit = true; + } else if ev.id == tray_for_timer.toggle_id { + toggle = true; } } - if let Ok(ev) = TrayIconEvent::receiver().try_recv() { - if let TrayIconEvent::DoubleClick { .. } = ev { - tray_for_timer.show_window(); + while let Ok(ev) = TrayIconEvent::receiver().try_recv() { + match ev { + // Act on the release, so pressing on the icon and dragging + // away doesn't count as a click. + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + | TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } => show = true, + _ => {} } } + + if exit { + let _ = slint::quit_event_loop(); + } else if show { + tray_for_timer.show_window(); + } else if toggle { + tray_for_timer.toggle_window(); + } }, ); std::mem::forget(timer); From afb826d33f2014bb18a6ab61a21daeae6bab22cc Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 12 Sep 2026 16:37:35 +0200 Subject: [PATCH 5/5] feat(linux): implement default_output_id for PulseAudio Completes the output-switch volume re-apply on Linux. Until now the trait method fell back to its `None` default there, so the actuator never noticed a default-sink change and the feature was Windows-only. Reads the default sink from the cache `refresh()` already fills from `pactl info`, so the actuator's 400ms poll costs no extra process. Written by the session that did the Linux fixes in #9 and handed over for this branch, since it needs both halves: the trait method from here and the rewritten pulse.rs from there. Co-Authored-By: Claude Opus 5 (1M context) --- src/audio/pulse.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/audio/pulse.rs b/src/audio/pulse.rs index a5dac70..2e97423 100644 --- a/src/audio/pulse.rs +++ b/src/audio/pulse.rs @@ -228,6 +228,18 @@ impl AudioBackend for PulseBackend { } } + /// The default sink's pactl name. Read from the same refresh cache, so the + /// actuator's poll for output-device switches costs no extra process. + fn default_output_id(&self) -> Option { + self.refresh(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.default_sink.is_empty() { + None + } else { + Some(c.default_sink.clone()) + } + } + fn list_processes(&self) -> Vec { self.refresh(); let c = self.cache.lock().unwrap_or_else(|e| e.into_inner());