diff --git a/Cargo.toml b/Cargo.toml index 18b220e..4146b31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,15 @@ 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", + # 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", + "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..877eb2e 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,17 @@ 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 (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 @@ -52,6 +69,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/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()); 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/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 new file mode 100644 index 0000000..182c698 --- /dev/null +++ b/src/discord.rs @@ -0,0 +1,610 @@ +//! 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"]; +/// 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). +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(); +/// 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. +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() +} + +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. +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(); + *error_cell().lock() = None; + 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}"); + *error_cell().lock() = Some(e.to_string()); + } + } + *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; + *error_cell().lock() = None; + 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 cached token and no Client Secret — paste the secret once 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(401, _) => 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}") + } + 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..b7df352 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)>, } @@ -307,6 +309,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 +357,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()); @@ -491,6 +495,31 @@ 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. + 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(); @@ -638,6 +667,11 @@ 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()); + ui.set_led_discord_hint(discord_hint(&s.settings).into()); }); } { @@ -745,6 +779,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 +867,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 +920,31 @@ 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_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(); + 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); }); @@ -973,6 +1034,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(); @@ -1109,7 +1197,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 +1212,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 +1234,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 +1259,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 +1284,10 @@ 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_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 { @@ -1208,6 +1315,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 +1421,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 +1452,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 +1503,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 +1673,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 +1690,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 +1708,39 @@ 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(), + media: c.media.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 — nothing with transport controls is open.".into() + } else { + 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 — 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 { + 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(), } } @@ -1541,11 +1750,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..afd0822 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().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 cb508fa..c12b13d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,15 +10,19 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] mod actuator; +mod app_groups; mod audio; mod autostart; +mod clipboard; 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 +124,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, @@ -132,6 +140,7 @@ fn main() -> anyhow::Result<()> { pending_wizard: None, pending_retry_deadline: None, editing_idx: None, + last_status_refresh: None, editing_led: None, })); @@ -284,7 +293,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..0514953 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,248 @@ +//! 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 +//! (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" (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 { + /// Every media session the OS reports, as (source id, status). + pub sessions: Vec<(String, MediaStatus)>, +} + +impl MediaState { + /// 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) + } +} + +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 with their status, for the UI hint. +pub fn sources() -> Vec<(String, MediaStatus)> { + state().sessions +} + +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; + } + 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 +} + +#[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 reports "nothing" there. + MediaState::default() +} + +#[cfg(test)] +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 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 eeb89a3..62e58ac 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, + /// 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, } /// 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,12 @@ 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, + /// For `Media`: the playback state to match. + #[serde(default)] + pub media: crate::media::MediaStatus, } /// Per-LED configuration. Conditions decide the `active` vs `inactive` look. @@ -471,6 +485,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 +506,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..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; @@ -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,16 @@ 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). +/// +/// 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 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()?; @@ -75,6 +147,9 @@ pub fn install(ui: &AppWindow) -> 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); @@ -87,6 +162,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. @@ -94,20 +170,50 @@ pub fn install(ui: &AppWindow) -> Option> { let timer = slint::Timer::default(); timer.start( slint::TimerMode::Repeated, - std::time::Duration::from_millis(120), + std::time::Duration::from_millis(100), move || { - 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(); + // 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(); + + // 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); diff --git a/ui/app.slint b/ui/app.slint index 036ef1c..4b8d443 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,9 @@ 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 · 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 export struct LedConditionUI { @@ -42,6 +48,7 @@ export struct LedConditionUI { cmp: int, value: int, method: int, url: string, payload: string, bearer: string, interval: string, expect-body: string, + signal: int, media: int, } // ────────────────────────────────────────────────────────────────────────── @@ -92,6 +99,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"); } } @@ -582,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); } } @@ -1066,6 +1074,10 @@ 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 discord-redirect; in property build-stamp; callback theme-changed(int); @@ -1074,6 +1086,8 @@ component SettingsPage inherits VerticalBox { callback curve-changed(); callback led-save-state(); callback led-clear-state(); + callback open-discord-portal(); + callback copy-discord-redirect(); spacing: 14px; @@ -1090,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; @@ -1189,6 +1207,86 @@ component SettingsPage inherits VerticalBox { Toggle { checked <=> root.led-experimental; toggled => { root.dirty(); } } } } } + if root.led-experimental: SettingRow { + label: "Discord (LED source)"; + 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"; + 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); } + } + } + + // 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."; @@ -1459,6 +1557,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 +1590,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 +1700,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 +1715,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 +1738,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 +1993,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 +2009,9 @@ component LedConfigPopup inherits Rectangle { callback refresh(); 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); callback cond-cmp(int, int); @@ -1990,7 +2125,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", + "Media", "Discord"]; current-index: c.kind; selected(i) => { root.cond-kind(idx, i); } } Rectangle { horizontal-stretch: 1; } @@ -2019,6 +2155,32 @@ component LedConfigPopup inherits Rectangle { horizontal-alignment: right; vertical-alignment: center; font-family: "Inter"; } } + // 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); } } + 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 +2269,10 @@ 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 property discord-redirect; in-out property unmute-on-change; in-out property start-with-os; in-out property start-minimized; @@ -2151,6 +2317,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 +2334,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: ""; @@ -2224,6 +2393,8 @@ export component AppWindow inherits Window { // ── LED state persistence (board EEPROM) ── 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 @@ -2234,6 +2405,9 @@ 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-media-state(int, int); + 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 +2619,10 @@ 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; + discord-redirect: root.discord-redirect; unmute-on-change <=> root.unmute-on-change; start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; @@ -2463,6 +2641,8 @@ 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(); } + copy-discord-redirect => { root.copy-discord-redirect(); } } } } @@ -2508,6 +2688,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 +2701,9 @@ 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-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); } cond-cmp(i, v) => { root.led-cond-cmp(i, v); } @@ -2543,6 +2728,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 +2739,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 +2764,7 @@ export component AppWindow inherits Window { root.wizard-bearer = ""; root.wizard-throttle = 500; root.wizard-filter = ""; + root.wizard-picked-group = false; } }