diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0deceda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +# Build and test on every push / PR. `release.yml` only ever fires on a tag, so +# without this nothing compiles the Linux target until release time — and the +# Linux-gated code (audio/pulse.rs, the non-Windows half of keys.rs, +# autostart.rs) is invisible to a Windows developer's compiler entirely. +on: + push: + branches-ignore: + - "gh-pages" + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + linux: + name: Linux (build + test) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libwayland-dev libxcb1-dev libfontconfig1-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: ci-linux + cache-all-crates: true + + # Type-checks the Linux-only modules that never see a compiler on a + # Windows dev machine. + - name: cargo check + run: cargo check --all-targets + + - name: cargo test + run: cargo test + + - name: cargo build --release + run: cargo build --release + + # The shipped Linux binary is a bare dynamically-linked ELF, so anything + # in DT_NEEDED must exist on a plain desktop. Print it so a newly + # introduced native dependency (libxdo, libpulse, ...) is visible in the + # log instead of surfacing as "it won't start" on a user's machine. + - name: Report runtime library dependencies + run: | + ldd target/release/slidr || true + echo "--- DT_NEEDED ---" + readelf -d target/release/slidr | grep NEEDED || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fee8e8c..87881aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - pkg-config libpulse-dev libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libwayland-dev libxcb1-dev libfontconfig1-dev libxdo-dev + pkg-config libudev-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libwayland-dev libxcb1-dev libfontconfig1-dev - name: Install dependencies (Windows build) if: matrix.target == 'windows' diff --git a/Cargo.lock b/Cargo.lock index de7c2ee..fb0ffa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,7 @@ dependencies = [ "log", "objc2 0.5.2", "windows 0.56.0", + "x11rb", "xkbcommon 0.7.0", "xkeysym", ] @@ -2629,7 +2630,7 @@ dependencies = [ "input-sys", "libc", "log", - "udev 0.9.3", + "udev", ] [[package]] @@ -2939,33 +2940,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libpulse-binding" -version = "2.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libpulse-sys", - "num-derive", - "num-traits", - "winapi", -] - -[[package]] -name = "libpulse-sys" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" -dependencies = [ - "libc", - "num-derive", - "num-traits", - "pkg-config", - "winapi", -] - [[package]] name = "libredox" version = "0.1.17" @@ -4944,7 +4918,6 @@ dependencies = [ "enigo", "env_logger", "image", - "libpulse-binding", "log", "once_cell", "open", @@ -4957,7 +4930,6 @@ dependencies = [ "slint-build", "thiserror 1.0.69", "tray-icon", - "udev 0.8.0", "ureq", "windows 0.58.0", "windows-core 0.58.0", @@ -5672,18 +5644,6 @@ dependencies = [ "serde", ] -[[package]] -name = "udev" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1" -dependencies = [ - "io-lifetimes", - "libc", - "libudev-sys", - "pkg-config", -] - [[package]] name = "udev" version = "0.9.3" diff --git a/Cargo.toml b/Cargo.toml index d985de6..18b220e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,14 @@ slint = { version = "1.16", default-features = false, features = ["std", "compat # Build timestamp injected at compile time chrono = { version = "0.4", default-features = false, features = ["clock"] } -# Key simulation (cross-platform) -enigo = "0.2" +# Key simulation (cross-platform). +# Linux: the `x11rb` backend (pure Rust XTEST) instead of the default `xdo` +# one. `xdo` is `#[link(name = "xdo")]`, which makes libxdo.so.3 a hard +# load-time dependency of the shipped binary - it would refuse to start on any +# machine without xdotool installed. `x11rb` has the same reach (X11 + +# XWayland) with no native library. Native Wayland clients are unreachable +# either way; `keys.rs` warns about that at startup. +enigo = { version = "0.2", default-features = false, features = ["x11rb"] } # Cross-platform "open file/url" open = "5" @@ -58,10 +64,6 @@ open = "5" # Native file dialog for "Browse…" buttons rfd = "0.15" -[target.'cfg(target_os = "linux")'.dependencies] -libpulse-binding = "2.28" -udev = "0.8" - [target.'cfg(target_os = "windows")'.dependencies] # Direct dep so the `#[interface]` macro's `::windows_core` paths resolve; pinned # to the same version `windows` re-exports as `windows::core` (shared types). diff --git a/README.md b/README.md index dc5734f..f58b0ab 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,42 @@ installer/slidr.nsi Interactive NSIS installer ```sh # Linux dev deps (Ubuntu 24.04 names): -sudo apt install build-essential pkg-config libpulse-dev libudev-dev \ +sudo apt install build-essential pkg-config libudev-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev \ - libxdo-dev libfontconfig1-dev + libfontconfig1-dev cargo run --release ``` +### Linux runtime requirements + +* **`pactl`** (Ubuntu/Debian/Fedora: `pulseaudio-utils`). The audio backend + shells out to it; it also ships with `pipewire-pulse`. Without it Slidr + starts but every volume/mute action is a no-op. +* **Serial port access.** Opening `/dev/ttyUSB*` / `/dev/ttyACM*` needs + permission, or the connection banner shows "Access denied". Either install + the bundled udev rule (preferred - applies on replug, no group, no re-login): + + ```sh + sudo install -m644 packaging/99-slidr.rules /etc/udev/rules.d/99-slidr.rules + sudo udevadm control --reload-rules && sudo udevadm trigger + ``` + + or add yourself to the port's group and log out and back in: + + ```sh + sudo usermod -aG dialout "$USER" + ``` +* **Key actions need an X11 session.** Simulated keypresses go out over XTEST, + which reaches X11 and XWayland windows but *not* native Wayland windows - a + Wayland compositor does not let an ordinary application inject input. On a + Wayland session Slidr logs a warning at startup and key actions will appear + to do nothing in Wayland-native apps; volume, mute, LED and API actions are + unaffected. Pick the "Xorg"/"X11" session at login if you need key actions. +* **No system tray.** The tray (`src/tray.rs`) is Windows-only, so the + tray-related settings are hidden on Linux and `--hidden` is ignored - it + would otherwise leave a window-less process that cannot be reopened. + Override the config location with `SLIDR_CONFIG_DIR=/path` (default `$XDG_CONFIG_HOME/slidr`, i.e. `~/.config/slidr`; `%APPDATA%/slidr` on Windows). diff --git a/deploy.sh b/deploy.sh index eab7628..38c1759 100755 --- a/deploy.sh +++ b/deploy.sh @@ -40,7 +40,8 @@ fi # ── Publish ────────────────────────────────────────────────────────────── log "Publishing to $WEBROOT" mkdir -p "$WEBROOT" -install -m644 target/release/slidr "$WEBROOT/Slidr-linux-x86_64" +# 0755: this is a binary users download and run, not a document. +install -m755 target/release/slidr "$WEBROOT/Slidr-linux-x86_64" if [[ "$SKIP_WINDOWS" -eq 0 ]]; then install -m644 installer/Slidr-windows-setup.exe "$WEBROOT/Slidr-windows-setup.exe" install -m644 "target/$WIN_TARGET/release/slidr.exe" "$WEBROOT/Slidr-windows-portable.exe" diff --git a/packaging/99-slidr.rules b/packaging/99-slidr.rules new file mode 100644 index 0000000..a46c3a6 --- /dev/null +++ b/packaging/99-slidr.rules @@ -0,0 +1,28 @@ +# udev rules for Slidr — grant the logged-in desktop user access to the board's +# serial port without adding them to a group or running the app as root. +# +# Install: +# sudo install -m644 packaging/99-slidr.rules /etc/udev/rules.d/99-slidr.rules +# sudo udevadm control --reload-rules && sudo udevadm trigger +# +# Without this (or membership in the port's owning group, usually `dialout`), +# opening /dev/ttyUSB* / /dev/ttyACM* fails with "Permission denied (os error 13)" +# and Slidr reports "Access denied" on the connection banner. +# +# The VID list mirrors KNOWN_USB_VIDS in src/serial.rs — keep the two in sync. + +# Arduino LLC (Uno, Mega, Nano 33, ...) +SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", TAG+="uaccess" +# Atmel / Arduino Leonardo, Micro, Pro Micro +SUBSYSTEM=="tty", ATTRS{idVendor}=="0d28", TAG+="uaccess" +# WCH CH340 / CH341 (most Nano clones) +SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", TAG+="uaccess" +# FTDI FT232 +SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", TAG+="uaccess" +# Silicon Labs CP210x +SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", TAG+="uaccess" + +# `uaccess` hands the device to whoever is logged in at the seat, which is what +# you want on a single-user desktop. On systems without systemd-logind, replace +# the TAG with a group instead, e.g.: +# SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", MODE="0660", GROUP="dialout" diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 3570acd..f7efd16 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -6,6 +6,9 @@ #[cfg(target_os = "linux")] pub mod pulse; +// Pure `pactl` output parsers. Compiled everywhere (not gated) so they stay +// unit-testable on any host; only `pulse` consumes them. +pub mod pulse_parse; #[cfg(target_os = "windows")] pub mod wasapi; pub mod null; diff --git a/src/audio/pulse.rs b/src/audio/pulse.rs index 49943fc..a5dac70 100644 --- a/src/audio/pulse.rs +++ b/src/audio/pulse.rs @@ -4,11 +4,23 @@ //! transparently supported by PipeWire via `pipewire-pulse`. Shelling out //! keeps the implementation tiny vs. binding to libpulse's async C API, //! and the action rate (≤60Hz, throttled) is well within budget. +//! +//! Two things matter for correctness here: +//! +//! * **Locale.** `pactl`'s human-readable output is translated, so every parse +//! below would break on a non-English desktop ("Sink-Eingang #", "Stumm: ja"). +//! Every invocation therefore forces `LC_ALL=C`. +//! * **Query cost.** A `pactl` call is a fork+exec. The LED engine polls mute +//! and volume 10×/s per LED, so answering those from a live process each time +//! meant dozens of processes per second at idle. `Volume:`/`Mute:` are already +//! part of the `pactl list` output we refresh anyway, so reads are served from +//! the same short-TTL cache and cost nothing extra. use std::process::{Command, Stdio}; use std::sync::Mutex; use std::time::{Duration, Instant}; +use super::pulse_parse::{parse_defaults, parse_records, strip_exe, Record}; use super::{AudioBackend, MuteTarget, VolumeTarget}; pub struct PulseBackend { @@ -17,31 +29,22 @@ pub struct PulseBackend { #[derive(Default)] struct Cache { - sink_inputs: Vec, - sources: Vec, - sinks: Vec, + sink_inputs: Vec, + sources: Vec, + sinks: Vec, + /// Names reported by `pactl info`, cached alongside the lists so resolving + /// "System (default)" costs no extra process per query. + default_sink: String, + default_source: String, stamp: Option, } -#[derive(Clone)] -struct SinkInput { - id: String, - process_binary: String, - app_name: String, -} - -#[derive(Clone)] -struct NamedId { - name: String, - description: String, -} - const CACHE_TTL: Duration = Duration::from_millis(500); impl PulseBackend { pub fn new() -> anyhow::Result { // Probe pactl presence + a working server. - let out = Command::new("pactl") + let out = pactl() .arg("info") .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -55,45 +58,112 @@ impl PulseBackend { } fn refresh(&self) { - let mut c = self.cache.lock().unwrap(); - if let Some(t) = c.stamp { - if t.elapsed() < CACHE_TTL { - return; + // Collect outside the lock so concurrent readers (UI thread listing + // devices while the actuator ticks) never queue behind three subprocesses. + { + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = c.stamp { + if t.elapsed() < CACHE_TTL { + return; + } } } - c.sink_inputs = list_sink_inputs(); - c.sources = list_endpoints("sources"); - c.sinks = list_endpoints("sinks"); + let sink_inputs = list("sink-inputs", "Sink Input #"); + let sources = list("sources", "Source #"); + let sinks = list("sinks", "Sink #"); + let (default_sink, default_source) = defaults(); + let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + c.sink_inputs = sink_inputs; + c.sources = sources; + c.sinks = sinks; + c.default_sink = default_sink; + c.default_source = default_source; c.stamp = Some(Instant::now()); } - fn sink_inputs_for(&self, process: &str) -> Vec { + /// Drop the cache so the next read reflects a change we just made. Used + /// after mute/default-sink changes so LED feedback is immediate; *not* used + /// after volume writes, which arrive at up to 60Hz during a slider drag and + /// whose ≤500ms staleness is invisible. + fn invalidate(&self) { + self.cache.lock().unwrap_or_else(|e| e.into_inner()).stamp = None; + } + + fn sink_inputs_for(&self, process: &str) -> Vec { self.refresh(); - let c = self.cache.lock().unwrap(); - let needle = process.to_lowercase(); - let needle = strip_exe(&needle); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let needle = strip_exe(&process.to_lowercase()); + if needle.is_empty() { + return Vec::new(); + } c.sink_inputs .iter() .filter(|si| { si.process_binary.to_lowercase().contains(&needle) || si.app_name.to_lowercase().contains(&needle) }) - .map(|si| si.id.clone()) + .cloned() .collect() } - fn match_endpoint(&self, kind: EndpointKind, name: Option<&str>) -> Option { + fn match_endpoint(&self, kind: EndpointKind, name: Option<&str>) -> Option { self.refresh(); - let c = self.cache.lock().unwrap(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let list = match kind { EndpointKind::Source => &c.sources, EndpointKind::Sink => &c.sinks, }; let name = name?; let n = name.to_lowercase(); + if n.is_empty() { + return None; + } list.iter() .find(|e| e.name.to_lowercase().contains(&n) || e.description.to_lowercase().contains(&n)) - .map(|e| e.name.clone()) + .cloned() + } + + /// Resolve an endpoint to its pactl name, falling back to the server's + /// default when the configured device isn't present. + fn endpoint_name(&self, kind: EndpointKind, name: Option<&str>) -> String { + self.match_endpoint(kind, name).map(|e| e.name).unwrap_or_else(|| { + match kind { + EndpointKind::Source => "@DEFAULT_SOURCE@".into(), + EndpointKind::Sink => "@DEFAULT_SINK@".into(), + } + }) + } + + /// Cached record for an endpoint, resolving `None`/unknown to the server + /// default by looking up the name `pactl get-default-{sink,source}` reports. + fn endpoint_record(&self, kind: EndpointKind, name: Option<&str>) -> Option { + if let Some(r) = self.match_endpoint(kind, name) { + return Some(r); + } + // Unconfigured or unresolvable: fall back to the server default, read + // from the same cache (no subprocess). + self.refresh(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let (list, default) = match kind { + EndpointKind::Source => (&c.sources, &c.default_source), + EndpointKind::Sink => (&c.sinks, &c.default_sink), + }; + list.iter().find(|e| &e.name == default).cloned() + } + + fn run(&self, subcmd: &str, args: &[&str]) { + let status = pactl() + .arg(subcmd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => log::debug!("pactl {subcmd} {args:?} exited with {s}"), + Err(e) => log::warn!("pactl {subcmd} failed to run: {e}"), + } } } @@ -103,138 +173,69 @@ enum EndpointKind { Sink, } -fn strip_exe(s: &str) -> String { - let s = s.trim_end_matches(".exe"); - // Use the basename if a path was provided. - s.rsplit(['/', '\\']).next().unwrap_or(s).to_string() -} impl AudioBackend for PulseBackend { fn set_volume(&self, target: VolumeTarget<'_>, value: f32) { let pct = format!("{}%", (value.clamp(0.0, 1.0) * 100.0).round() as i32); match target { VolumeTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-volume", &[&id, &pct]); + for si in self.sink_inputs_for(p) { + self.run("set-sink-input-volume", &[&si.id, &pct]); } } VolumeTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-volume", &[&name, &pct]); + let name = self.endpoint_name(EndpointKind::Source, Some(m)); + self.run("set-source-volume", &[&name, &pct]); } VolumeTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-volume", &[&name, &pct]); + let name = self.endpoint_name(EndpointKind::Sink, d); + self.run("set-sink-volume", &[&name, &pct]); } } } fn set_mute(&self, target: MuteTarget<'_>, muted: bool) { let flag = if muted { "1" } else { "0" }; - match target { - MuteTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-mute", &[&id, flag]); - } - } - MuteTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-mute", &[&name, flag]); - } - MuteTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-mute", &[&name, flag]); - } - } + self.apply_mute(target, flag); } fn toggle_mute(&self, target: MuteTarget<'_>) { - match target { - MuteTarget::Process(p) => { - for id in self.sink_inputs_for(p) { - run("set-sink-input-mute", &[&id, "toggle"]); - } - } - MuteTarget::Mic(m) => { - let name = self - .match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()); - run("set-source-mute", &[&name, "toggle"]); - } - MuteTarget::System(d) => { - let name = self - .match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()); - run("set-sink-mute", &[&name, "toggle"]); - } - } + self.apply_mute(target, "toggle"); } fn is_muted(&self, target: MuteTarget<'_>) -> bool { - let (subcmd, name) = match target { + // Served entirely from the refresh cache — no subprocess. + match target { MuteTarget::Process(p) => { - let ids = self.sink_inputs_for(p); - let Some(id) = ids.first() else { return false }; - ("get-sink-input-mute", id.clone()) + self.sink_inputs_for(p).first().map(|si| si.muted).unwrap_or(false) } - MuteTarget::Mic(m) => ( - "get-source-mute", - self.match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()), - ), - MuteTarget::System(d) => ( - "get-sink-mute", - self.match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()), - ), - }; - capture(subcmd, &[&name]) - .map(|o| o.contains("yes")) - .unwrap_or(false) + MuteTarget::Mic(m) => self + .endpoint_record(EndpointKind::Source, Some(m)) + .map(|e| e.muted) + .unwrap_or(false), + MuteTarget::System(d) => self + .endpoint_record(EndpointKind::Sink, d) + .map(|e| e.muted) + .unwrap_or(false), + } } fn get_volume(&self, target: VolumeTarget<'_>) -> Option { - let (subcmd, name) = match target { - VolumeTarget::Process(p) => { - let ids = self.sink_inputs_for(p); - ("get-sink-input-volume", ids.first()?.clone()) - } - VolumeTarget::Mic(m) => ( - "get-source-volume", - self.match_endpoint(EndpointKind::Source, Some(m)) - .unwrap_or_else(|| "@DEFAULT_SOURCE@".into()), - ), - VolumeTarget::System(d) => ( - "get-sink-volume", - self.match_endpoint(EndpointKind::Sink, d) - .unwrap_or_else(|| "@DEFAULT_SINK@".into()), - ), - }; - let out = capture(subcmd, &[&name])?; - parse_first_percent(&out).map(|p| (p as f32 / 100.0).clamp(0.0, 1.0)) + match target { + VolumeTarget::Process(p) => self.sink_inputs_for(p).first()?.volume, + VolumeTarget::Mic(m) => self.endpoint_record(EndpointKind::Source, Some(m))?.volume, + VolumeTarget::System(d) => self.endpoint_record(EndpointKind::Sink, d)?.volume, + } } fn list_processes(&self) -> Vec { self.refresh(); - let c = self.cache.lock().unwrap(); + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let mut names: Vec = c .sink_inputs .iter() - .map(|si| { - if !si.process_binary.is_empty() { - si.process_binary.clone() - } else { - si.app_name.clone() - } - }) + .map(Record::process_label) + .filter(|n| !n.is_empty()) .collect(); names.sort(); names.dedup(); @@ -243,17 +244,29 @@ impl AudioBackend for PulseBackend { fn list_mics(&self) -> Vec { self.refresh(); - self.cache.lock().unwrap().sources.iter().map(|e| e.description.clone()).collect() + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + // Skip `.monitor` sources: those are loopbacks of output devices, not + // capture hardware, and only clutter the microphone picker. + let mut names: Vec = c + .sources + .iter() + .filter(|s| !s.monitor) + .map(Record::label) + .filter(|n| !n.is_empty()) + .collect(); + names.dedup(); + names } fn list_outputs(&self) -> Vec { self.refresh(); - self.cache.lock().unwrap().sinks.iter().map(|e| e.description.clone()).collect() + let c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + c.sinks.iter().map(Record::label).filter(|n| !n.is_empty()).collect() } fn cycle_output(&self, devices: &[String]) { self.refresh(); - let sinks = self.cache.lock().unwrap().sinks.clone(); + let sinks = self.cache.lock().unwrap_or_else(|e| e.into_inner()).sinks.clone(); // Cycle order as pactl sink names. Empty list = every sink; otherwise map // each configured description (or name) to its sink, skipping any missing. let order: Vec = if devices.is_empty() { @@ -279,103 +292,69 @@ impl AudioBackend for PulseBackend { let cur = capture("get-default-sink", &[]).map(|s| s.trim().to_string()); let cur_idx = cur.as_ref().and_then(|c| order.iter().position(|n| n == c)); let next = cur_idx.map_or(0, |i| (i + 1) % order.len()); - run("set-default-sink", &[&order[next]]); + self.run("set-default-sink", &[&order[next]]); + self.invalidate(); } } -fn run(subcmd: &str, args: &[&str]) { - let _ = Command::new("pactl") - .arg(subcmd) - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); +impl PulseBackend { + fn apply_mute(&self, target: MuteTarget<'_>, flag: &str) { + match target { + MuteTarget::Process(p) => { + for si in self.sink_inputs_for(p) { + self.run("set-sink-input-mute", &[&si.id, flag]); + } + } + MuteTarget::Mic(m) => { + let name = self.endpoint_name(EndpointKind::Source, Some(m)); + self.run("set-source-mute", &[&name, flag]); + } + MuteTarget::System(d) => { + let name = self.endpoint_name(EndpointKind::Sink, d); + self.run("set-sink-mute", &[&name, flag]); + } + } + // Mute changes are user-initiated and rare; refresh now so the LED + // engine reflects the new state on its next tick rather than up to + // CACHE_TTL later. + self.invalidate(); + } +} + +/// A `pactl` command with the locale pinned. Without this every `starts_with` +/// below fails on a translated desktop. +fn pactl() -> Command { + let mut c = Command::new("pactl"); + c.env("LC_ALL", "C").env("LANG", "C").env_remove("LANGUAGE"); + c } fn capture(subcmd: &str, args: &[&str]) -> Option { - let out = Command::new("pactl") + let out = pactl() .arg(subcmd) .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) .output() .ok()?; - Some(String::from_utf8_lossy(&out.stdout).into_owned()) -} - -fn list_sink_inputs() -> Vec { - let Some(text) = capture("list", &["sink-inputs"]) else { return Vec::new() }; - let mut out = Vec::new(); - let mut cur: Option = None; - for raw in text.lines() { - let line = raw.trim(); - if let Some(rest) = line.strip_prefix("Sink Input #") { - if let Some(c) = cur.take() { - out.push(c); - } - cur = Some(SinkInput { - id: rest.trim().to_string(), - process_binary: String::new(), - app_name: String::new(), - }); - } else if let Some(c) = cur.as_mut() { - if let Some(v) = property(line, "application.process.binary") { - c.process_binary = v; - } else if let Some(v) = property(line, "application.name") { - if c.app_name.is_empty() { - c.app_name = v; - } - } - } - } - if let Some(c) = cur { - out.push(c); + if !out.status.success() { + return None; } - out + Some(String::from_utf8_lossy(&out.stdout).into_owned()) } -fn list_endpoints(kind: &str) -> Vec { +/// Parse `pactl list `. All three entity kinds share one layout: a header +/// line `` followed by indented `Key: value` fields and a +/// `Properties:` block of `key = "value"` lines. +fn list(kind: &str, header_prefix: &str) -> Vec { let Some(text) = capture("list", &[kind]) else { return Vec::new() }; - let mut out = Vec::new(); - let mut name = String::new(); - let mut desc = String::new(); - for raw in text.lines() { - let line = raw.trim_start(); - if line.starts_with("Name: ") { - if !name.is_empty() { - out.push(NamedId { name: std::mem::take(&mut name), description: std::mem::take(&mut desc) }); - } - name = line[6..].trim().to_string(); - } else if line.starts_with("Description: ") { - desc = line[13..].trim().to_string(); - } - } - if !name.is_empty() { - out.push(NamedId { name, description: desc }); - } - out + parse_records(&text, header_prefix) } -/// Parse the first `%` occurrence (pactl volume output) into a percent. -fn parse_first_percent(s: &str) -> Option { - let bytes = s.as_bytes(); - for i in 0..bytes.len() { - if bytes[i] == b'%' { - let mut j = i; - while j > 0 && bytes[j - 1].is_ascii_digit() { - j -= 1; - } - if j < i { - return s[j..i].parse().ok(); - } - } - } - None +/// `(default_sink, default_source)` from a single `pactl info` call. +fn defaults() -> (String, String) { + let Some(text) = capture("info", &[]) else { return (String::new(), String::new()) }; + parse_defaults(&text) } -fn property(line: &str, key: &str) -> Option { - // pactl prints `key = "value"` (with leading whitespace). - let prefix = format!("{key} = \""); - let idx = line.find(&prefix)?; - let rest = &line[idx + prefix.len()..]; - let end = rest.rfind('"')?; - Some(rest[..end].to_string()) -} + diff --git a/src/audio/pulse_parse.rs b/src/audio/pulse_parse.rs new file mode 100644 index 0000000..53d4d33 --- /dev/null +++ b/src/audio/pulse_parse.rs @@ -0,0 +1,262 @@ +//! Parsers for `pactl` textual output. +//! +//! Split out of `pulse.rs` and compiled on every platform so the parsing -- +//! the part most likely to regress, and the part that silently broke on +//! non-English desktops -- is unit-testable anywhere, including Windows CI. +//! Everything here is pure: no process spawning, no I/O. + +#![allow(dead_code)] // only the Linux backend consumes these + +#[derive(Clone, Default)] +pub struct Record { + pub id: String, + pub name: String, + pub description: String, + pub process_binary: String, + pub app_name: String, + pub volume: Option, + pub muted: bool, + /// Sources only: true for the `.monitor` loopback of an output device. + pub monitor: bool, +} + +impl Record { + /// Best human-readable label for a device. + pub fn label(&self) -> String { + if self.description.is_empty() { + self.name.clone() + } else { + self.description.clone() + } + } + + /// Best human-readable label for a playback stream. + pub fn process_label(&self) -> String { + if self.process_binary.is_empty() { + self.app_name.clone() + } else { + self.process_binary.clone() + } + } +} + +pub fn strip_exe(s: &str) -> String { + let s = s.strip_suffix(".exe").unwrap_or(s); + // Use the basename if a path was provided. + s.rsplit(['/', '\\']).next().unwrap_or(s).to_string() +} + +pub fn parse_defaults(text: &str) -> (String, String) { + let mut sink = String::new(); + let mut source = String::new(); + for raw in text.lines() { + let line = raw.trim(); + if let Some(v) = line.strip_prefix("Default Sink: ") { + sink = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Default Source: ") { + source = v.trim().to_string(); + } + } + (sink, source) +} + +pub fn parse_records(text: &str, header_prefix: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut cur: Option = None; + for raw in text.lines() { + let line = raw.trim(); + if let Some(rest) = line.strip_prefix(header_prefix) { + if let Some(c) = cur.take() { + out.push(c); + } + cur = Some(Record { id: rest.trim().to_string(), ..Default::default() }); + continue; + } + let Some(c) = cur.as_mut() else { continue }; + + if let Some(v) = line.strip_prefix("Name: ") { + c.name = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Description: ") { + c.description = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("Mute: ") { + c.muted = v.trim().eq_ignore_ascii_case("yes"); + } else if let Some(v) = line.strip_prefix("Volume: ") { + // Only the plain `Volume:` line — `Base Volume:` also carries a + // percentage and would otherwise win for sinks/sources. + c.volume = parse_first_percent(v).map(|p| (p as f32 / 100.0).clamp(0.0, 1.0)); + } else if let Some(v) = line.strip_prefix("Monitor of Sink: ") { + c.monitor = v.trim() != "n/a"; + } else if let Some(v) = property(line, "application.process.binary") { + c.process_binary = v; + } else if let Some(v) = property(line, "application.name") { + if c.app_name.is_empty() { + c.app_name = v; + } + } + } + if let Some(c) = cur { + out.push(c); + } + out +} + +fn parse_first_percent(s: &str) -> Option { + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + if bytes[i] == b'%' { + let mut j = i; + while j > 0 && bytes[j - 1].is_ascii_digit() { + j -= 1; + } + if j < i { + return s[j..i].parse().ok(); + } + } + } + None +} + +fn property(line: &str, key: &str) -> Option { + // pactl prints `key = "value"` (with leading whitespace). + let prefix = format!("{key} = \""); + let idx = line.find(&prefix)?; + let rest = &line[idx + prefix.len()..]; + let end = rest.rfind('"')?; + Some(rest[..end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SINKS: &str = "\ +Sink #0 +\tState: RUNNING +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Audio Analog Stereo +\tMute: no +\tVolume: front-left: 42926 / 65% / -11.20 dB, front-right: 42926 / 65% / -11.20 dB +\t balance 0.00 +\tBase Volume: 65536 / 100% / 0.00 dB +\tMonitor Source: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor +\tProperties: +\t\tdevice.description = \"Built-in Audio Analog Stereo\" +Sink #1 +\tState: SUSPENDED +\tName: alsa_output.usb-headset +\tDescription: USB Headset +\tMute: yes +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tBase Volume: 65536 / 100% / 0.00 dB +"; + + const SOURCES: &str = "\ +Source #0 +\tName: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor +\tDescription: Monitor of Built-in Audio +\tMute: no +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tMonitor of Sink: alsa_output.pci-0000_00_1f.3.analog-stereo +Source #1 +\tName: alsa_input.pci-0000_00_1f.3.analog-stereo +\tDescription: Built-in Microphone +\tMute: yes +\tVolume: front-left: 32768 / 50% / -18.06 dB +\tMonitor of Sink: n/a +"; + + const SINK_INPUTS: &str = "\ +Sink Input #412 +\tDriver: protocol-native.c +\tSink: 0 +\tMute: no +\tVolume: front-left: 45875 / 70% / -9.30 dB +\t balance 0.00 +\tProperties: +\t\tapplication.name = \"Spotify\" +\t\tapplication.process.binary = \"spotify\" +Sink Input #413 +\tSink: 0 +\tMute: yes +\tVolume: front-left: 65536 / 100% / 0.00 dB +\tProperties: +\t\tapplication.name = \"Firefox\" +"; + + #[test] + fn parses_sinks_with_volume_and_mute() { + let r = parse_records(SINKS, "Sink #"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].id, "0"); + assert_eq!(r[0].description, "Built-in Audio Analog Stereo"); + assert!(!r[0].muted); + assert_eq!(r[0].volume, Some(0.65)); + assert!(r[1].muted); + assert_eq!(r[1].volume, Some(1.0)); + } + + /// `Base Volume:` must not override the live `Volume:` line. + #[test] + fn base_volume_is_ignored() { + let r = parse_records(SINKS, "Sink #"); + assert_eq!(r[0].volume, Some(0.65)); + } + + #[test] + fn detects_monitor_sources() { + let r = parse_records(SOURCES, "Source #"); + assert_eq!(r.len(), 2); + assert!(r[0].monitor, "monitor source not flagged"); + assert!(!r[1].monitor); + assert!(r[1].muted); + assert_eq!(r[1].volume, Some(0.5)); + } + + #[test] + fn parses_sink_inputs_with_properties() { + let r = parse_records(SINK_INPUTS, "Sink Input #"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].id, "412"); + assert_eq!(r[0].process_binary, "spotify"); + assert_eq!(r[0].app_name, "Spotify"); + assert_eq!(r[0].volume, Some(0.7)); + assert!(!r[0].muted); + // No process.binary property -> falls back to the application name. + assert_eq!(r[1].process_label(), "Firefox"); + assert!(r[1].muted); + } + + #[test] + fn sink_header_does_not_swallow_sink_inputs() { + assert!(parse_records(SINK_INPUTS, "Sink #").is_empty()); + } + + #[test] + fn strip_exe_handles_paths_and_repeats() { + assert_eq!(strip_exe("spotify.exe"), "spotify"); + assert_eq!(strip_exe("c:\\apps\\discord.exe"), "discord"); + assert_eq!(strip_exe("/usr/bin/firefox"), "firefox"); + // Only one suffix is stripped (trim_end_matches used to eat them all). + assert_eq!(strip_exe("weird.exe.exe"), "weird.exe"); + } + + #[test] + fn parses_pactl_info_defaults() { + let info = "\ +Server String: /run/user/1000/pulse/native +Server Name: PulseAudio (on PipeWire 1.0.5) +Default Sink: alsa_output.usb-headset +Default Source: alsa_input.pci-0000_00_1f.3.analog-stereo +Cookie: 1234:5678 +"; + let (sink, source) = parse_defaults(info); + assert_eq!(sink, "alsa_output.usb-headset"); + assert_eq!(source, "alsa_input.pci-0000_00_1f.3.analog-stereo"); + } + + #[test] + fn percent_parsing() { + assert_eq!(parse_first_percent("front-left: 42926 / 65% / -11.20 dB"), Some(65)); + assert_eq!(parse_first_percent("no percentage here"), None); + } +} diff --git a/src/autostart.rs b/src/autostart.rs index 1e1e4b4..3439198 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -3,7 +3,10 @@ //! * Linux: XDG `~/.config/autostart/slidr.desktop` //! * Windows: `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Slidr` -use std::path::PathBuf; +/// Whether this build has a system tray to minimise into. Only Windows does +/// (`src/tray.rs`), so elsewhere `--hidden` is never written into the autostart +/// entry: it would start a process with no window and no way to open one. +const HAS_TRAY: bool = cfg!(target_os = "windows"); #[cfg(target_os = "linux")] pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { @@ -16,17 +19,18 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { return Ok(()); } let exe = std::env::current_exe()?; - let exe = exe.display(); - let exec_line = if start_minimized { - format!("{exe} --hidden") - } else { - format!("{exe}") - }; + let mut exec_line = quote_exec_arg(&exe.to_string_lossy()); + if start_minimized && HAS_TRAY { + exec_line.push_str(" --hidden"); + } let desktop = format!( "[Desktop Entry]\n\ Type=Application\n\ Name=Slidr\n\ + Comment=Arduino-based audio mixer and macro pad\n\ + Icon=slidr\n\ Exec={exec_line}\n\ + Categories=AudioVideo;Audio;Utility;\n\ X-GNOME-Autostart-enabled=true\n\ Terminal=false\n" ); @@ -37,15 +41,64 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { Ok(()) } +/// Quote a path for a desktop-entry `Exec=` value. +/// +/// Unquoted `Exec` splits on whitespace, so an install under e.g. +/// `~/My Apps/slidr` would never launch. The desktop-entry spec wants the +/// argument in double quotes with `"`, `` ` ``, `$` and `\` backslash-escaped. +#[cfg(target_os = "linux")] +fn quote_exec_arg(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + if matches!(c, '"' | '`' | '$' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + #[cfg(target_os = "linux")] pub fn is_enabled() -> bool { autostart_file().exists() } +/// Rewrite the autostart entry if it exists but points at a different binary. +/// +/// The entry stores an absolute path, so moving, renaming or reinstalling the +/// executable leaves a file that silently fails at login. Called once at +/// startup; a no-op when autostart is off or already current. +#[cfg(target_os = "linux")] +pub fn resync(start_minimized: bool) { + let path = autostart_file(); + if !path.exists() { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let current = quote_exec_arg(&exe.to_string_lossy()); + let stale = match std::fs::read_to_string(&path) { + Ok(text) => !text + .lines() + .any(|l| l.strip_prefix("Exec=").is_some_and(|v| v.starts_with(¤t))), + Err(e) => { + log::warn!("autostart: cannot read {}: {e}", path.display()); + return; + } + }; + if stale { + log::info!("autostart entry points elsewhere; rewriting for {}", exe.display()); + if let Err(e) = set_enabled(true, start_minimized) { + log::warn!("autostart: rewrite failed: {e}"); + } + } +} + #[cfg(target_os = "linux")] -fn autostart_file() -> PathBuf { +fn autostart_file() -> std::path::PathBuf { dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("autostart") .join("slidr.desktop") } @@ -57,7 +110,6 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { RegCloseKey, RegCreateKeyExW, RegDeleteValueW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, REG_SZ, }; - use windows::Win32::Foundation::HANDLE as _HANDLE; let subkey = HSTRING::from("Software\\Microsoft\\Windows\\CurrentVersion\\Run"); let name = HSTRING::from("Slidr"); @@ -78,7 +130,7 @@ pub fn set_enabled(enable: bool, start_minimized: bool) -> anyhow::Result<()> { if enable { let exe = std::env::current_exe()?; let mut cmd = format!("\"{}\"", exe.display()); - if start_minimized { + if start_minimized && HAS_TRAY { cmd.push_str(" --hidden"); } let wide: Vec = cmd.encode_utf16().chain(std::iter::once(0)).collect(); @@ -101,7 +153,24 @@ pub fn is_enabled() -> bool { false } -#[allow(dead_code)] -fn _path_unused() -> Option { - None +/// The registry value is rewritten on every settings save, so there is nothing +/// to repair on Windows. +#[cfg(target_os = "windows")] +pub fn resync(_start_minimized: bool) {} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::quote_exec_arg; + + #[test] + fn quotes_paths_with_spaces() { + assert_eq!(quote_exec_arg("/home/u/My Apps/slidr"), "\"/home/u/My Apps/slidr\""); + } + + #[test] + fn escapes_reserved_characters() { + assert_eq!(quote_exec_arg("/opt/a$b"), "\"/opt/a\\$b\""); + assert_eq!(quote_exec_arg("/opt/a\"b"), "\"/opt/a\\\"b\""); + assert_eq!(quote_exec_arg("/opt/a\\b"), "\"/opt/a\\\\b\""); + } } diff --git a/src/glue.rs b/src/glue.rs index 62fa319..8acf397 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -1173,6 +1173,10 @@ fn stream_secondary(s: &AudioStream) -> String { /// Push system (global) + appearance/slider (per-profile) settings into the UI. fn push_settings_to_ui(ui: &AppWindow, global: &Settings, profile: &crate::model::ProfileSettings) { // System (global) + // Gates the two tray rows: only Windows ships a tray (`src/tray.rs`), and + // without one "start minimized" would leave the app running with no window + // and no way to reopen it. + ui.set_has_tray(cfg!(target_os = "windows")); ui.set_start_with_os(global.start_with_os); ui.set_start_minimized(global.start_minimized); ui.set_minimize_to_tray(global.minimize_to_tray); @@ -1393,16 +1397,41 @@ fn list_running_processes() -> Vec { for e in rd.flatten() { let n = e.file_name(); let n = n.to_string_lossy(); - if !n.chars().all(|c| c.is_ascii_digit()) { continue } + if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { continue } if let Ok(comm) = std::fs::read_to_string(format!("/proc/{n}/comm")) { let comm = comm.trim(); - if !comm.is_empty() { out.push(comm.to_string()); } + if comm.is_empty() { continue } + out.push(untruncate_comm(&n, comm)); } } } out } +/// The kernel caps `/proc//comm` at 15 characters (`TASK_COMM_LEN - 1`), so +/// long binaries show up clipped ("gnome-terminal-", "systemd-journal") and then +/// fail to match what PulseAudio reports as `application.process.binary`. When a +/// name is exactly at the cap, recover the full one from `cmdline`'s argv[0]. +#[cfg(target_os = "linux")] +fn untruncate_comm(pid: &str, comm: &str) -> String { + const TASK_COMM_MAX: usize = 15; + if comm.len() < TASK_COMM_MAX { + return comm.to_string(); + } + let Ok(cmdline) = std::fs::read_to_string(format!("/proc/{pid}/cmdline")) else { + return comm.to_string(); + }; + let argv0 = cmdline.split('\0').next().unwrap_or(""); + let base = argv0.rsplit('/').next().unwrap_or(""); + // Only trust it when it really is the same program, just not clipped - + // argv[0] can be an interpreter path or a rewritten title. + if base.len() > comm.len() && base.starts_with(comm) { + base.to_string() + } else { + comm.to_string() + } +} + #[cfg(target_os = "windows")] fn list_running_processes() -> Vec { use windows::Win32::System::Diagnostics::ToolHelp::{ diff --git a/src/keys.rs b/src/keys.rs index d32e204..b36b0f5 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -3,6 +3,12 @@ //! `hold_start(vk)` emits the initial key-down and, if the key is "tap-only" //! (media keys etc.), schedules repeats at the system repeat rate. Regular //! keys are held physically and rely on the OS auto-repeat. +//! +//! The wire format everywhere in Slidr (presets, `keys_library`, the board +//! protocol) is a **Windows Virtual-Key code**. That is only directly usable by +//! `enigo` on Windows — on Linux `Key::Other` is interpreted as an *X11 keysym*, +//! which shares numbering with VK codes for ASCII letters/digits and for nothing +//! else. So VK codes are translated at the enigo boundary; see `platform_key`. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -12,6 +18,29 @@ use std::time::Duration; use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings}; +/// Translate a Windows VK code into the key representation the platform's +/// `enigo` backend expects. `None` = no sensible mapping (caller skips the key). +#[cfg(target_os = "windows")] +fn platform_key(vk: u32) -> Option { + Some(Key::Other(vk)) +} + +/// On Linux `Key::Other(v)` is fed to `Keysym::from(v)`, so we must hand enigo +/// an X11 keysym, not a VK code. Without this, F-keys type letters, arrows type +/// punctuation and every media/modifier key is a silent no-op. +#[cfg(not(target_os = "windows"))] +fn platform_key(vk: u32) -> Option { + vk_to_keysym(vk).map(Key::Other) +} + +#[cfg(not(target_os = "windows"))] +use crate::keys_vk::vk_to_keysym; + + +/// Initial delay before a tap-only key starts repeating, and the repeat period. +const REPEAT_DELAY: Duration = Duration::from_millis(500); +const REPEAT_PERIOD: Duration = Duration::from_millis(33); + pub struct KeyController { enigo: Enigo, holds: HashMap>, @@ -19,6 +48,7 @@ pub struct KeyController { impl KeyController { pub fn new() -> anyhow::Result { + warn_if_no_x11(); let enigo = Enigo::new(&EnigoSettings::default()) .map_err(|e| anyhow::anyhow!("Enigo init failed: {e:?}"))?; Ok(Self { enigo, holds: HashMap::new() }) @@ -28,7 +58,10 @@ impl KeyController { if self.holds.contains_key(&vk) { return; } - let key = Key::Other(vk); + let Some(key) = platform_key(vk) else { + log::warn!("key {vk} (0x{vk:02X}) has no mapping on this platform; ignored"); + return; + }; if let Err(e) = self.enigo.key(key, Direction::Press) { log::warn!("key press {vk} failed: {e:?}"); return; @@ -41,17 +74,7 @@ impl KeyController { let _ = self.enigo.key(key, Direction::Release); let cancel = Arc::new(AtomicBool::new(false)); self.holds.insert(vk, cancel.clone()); - // Spawn a repeater. Conservative defaults: 500ms initial, 33ms repeat. - thread::spawn(move || { - thread::sleep(Duration::from_millis(500)); - while !cancel.load(Ordering::Relaxed) { - // We can't reuse `Enigo` here (not Send); spawn a fresh one. - if let Ok(mut e) = Enigo::new(&EnigoSettings::default()) { - let _ = e.key(Key::Other(vk), Direction::Click); - } - thread::sleep(Duration::from_millis(33)); - } - }); + spawn_repeater(vk, cancel); } else { self.holds.insert(vk, Arc::new(AtomicBool::new(false))); } @@ -61,9 +84,58 @@ impl KeyController { if let Some(cancel) = self.holds.remove(&vk) { cancel.store(true, Ordering::Relaxed); } - let key = Key::Other(vk); + let Some(key) = platform_key(vk) else { return }; if let Err(e) = self.enigo.key(key, Direction::Release) { log::debug!("key release {vk}: {e:?}"); } } } + +/// Repeat `vk` until cancelled. `Enigo` is not `Send`, so the thread builds its +/// own and **keeps** it for the whole hold — the previous version constructed a +/// fresh one per tick, which on Linux meant opening an X11 connection and +/// re-uploading a keymap 30x/s. +fn spawn_repeater(vk: u32, cancel: Arc) { + let _ = thread::Builder::new() + .name("slidr-key-repeat".into()) + .spawn(move || { + thread::sleep(REPEAT_DELAY); + if cancel.load(Ordering::Relaxed) { + return; + } + let Some(key) = platform_key(vk) else { return }; + let Ok(mut enigo) = Enigo::new(&EnigoSettings::default()) else { + log::warn!("key repeat {vk}: cannot open input connection"); + return; + }; + while !cancel.load(Ordering::Relaxed) { + if let Err(e) = enigo.key(key, Direction::Click) { + log::debug!("key repeat {vk} stopped: {e:?}"); + return; + } + thread::sleep(REPEAT_PERIOD); + } + }); +} + +/// enigo is built with the `x11rb` backend only, which reaches native X11 +/// clients and XWayland clients but *not* native Wayland clients. Say so once, +/// loudly, instead of letting key actions silently do nothing. +#[cfg(not(target_os = "windows"))] +fn warn_if_no_x11() { + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false); + if wayland { + log::warn!( + "Wayland session detected: simulated keys are delivered over XTEST and will only \ + reach XWayland (X11) windows, not native Wayland windows. Log in with an X11/Xorg \ + session if you need key actions to work everywhere." + ); + } + if std::env::var_os("DISPLAY").is_none() { + log::warn!("$DISPLAY is unset; key actions will be unavailable"); + } +} + +#[cfg(target_os = "windows")] +fn warn_if_no_x11() {} diff --git a/src/keys_vk.rs b/src/keys_vk.rs new file mode 100644 index 0000000..8e397a2 --- /dev/null +++ b/src/keys_vk.rs @@ -0,0 +1,130 @@ +//! Windows Virtual-Key -> X11 keysym lookup. +//! +//! Split out of `keys.rs` and compiled on every platform (not just Linux) so +//! the table is unit-testable anywhere, including on the Windows CI runner. +//! `keys.rs` only consults it on non-Windows targets. + +#![allow(dead_code)] // unused on Windows, where VK codes are passed through + +/// Windows Virtual-Key → X11 keysym (`keysymdef.h` / `XF86keysym.h`). +pub fn vk_to_keysym(vk: u32) -> Option { + // Letters map to the *lowercase* keysym: VK 0x41 ('A') on Windows produces + // an unshifted keypress, and the X11 uppercase keysym would imply Shift. + if (0x41..=0x5A).contains(&vk) { + return Some(vk + 0x20); + } + // Digits and space share numbering with ASCII/keysyms. + if (0x30..=0x39).contains(&vk) || vk == 0x20 { + return Some(vk); + } + // F1..F24 -> XK_F1..XK_F24 (contiguous in both numberings). + if (0x70..=0x87).contains(&vk) { + return Some(0xFFBE + (vk - 0x70)); + } + // Numpad 0..9 -> XK_KP_0..XK_KP_9. + if (0x60..=0x69).contains(&vk) { + return Some(0xFFB0 + (vk - 0x60)); + } + Some(match vk { + 0x08 => 0xFF08, // Backspace + 0x09 => 0xFF09, // Tab + 0x0D => 0xFF0D, // Return + 0x10 => 0xFFE1, // Shift_L + 0x11 => 0xFFE3, // Control_L + 0x12 => 0xFFE9, // Alt_L + 0x13 => 0xFF13, // Pause + 0x14 => 0xFFE5, // Caps_Lock + 0x1B => 0xFF1B, // Escape + 0x21 => 0xFF55, // Prior (Page Up) + 0x22 => 0xFF56, // Next (Page Down) + 0x23 => 0xFF57, // End + 0x24 => 0xFF50, // Home + 0x25 => 0xFF51, // Left + 0x26 => 0xFF52, // Up + 0x27 => 0xFF53, // Right + 0x28 => 0xFF54, // Down + 0x2C => 0xFF61, // Print + 0x2D => 0xFF63, // Insert + 0x2E => 0xFFFF, // Delete + 0x5B => 0xFFEB, // Super_L + 0x5C => 0xFFEC, // Super_R + 0x5D => 0xFF67, // Menu + 0x6A => 0xFFAA, // KP_Multiply + 0x6B => 0xFFAB, // KP_Add + 0x6C => 0xFFAC, // KP_Separator + 0x6D => 0xFFAD, // KP_Subtract + 0x6E => 0xFFAE, // KP_Decimal + 0x6F => 0xFFAF, // KP_Divide + 0x90 => 0xFF7F, // Num_Lock + 0x91 => 0xFF14, // Scroll_Lock + 0xA0 => 0xFFE1, // Shift_L + 0xA1 => 0xFFE2, // Shift_R + 0xA2 => 0xFFE3, // Control_L + 0xA3 => 0xFFE4, // Control_R + 0xA4 => 0xFFE9, // Alt_L + 0xA5 => 0xFFEA, // Alt_R + // Browser keys (XF86) + 0xA6 => 0x1008FF26, // Back + 0xA7 => 0x1008FF27, // Forward + 0xA8 => 0x1008FF73, // Reload + 0xA9 => 0x1008FF28, // Stop + 0xAA => 0x1008FF1B, // Search + 0xAB => 0x1008FF30, // Favorites + 0xAC => 0x1008FF18, // HomePage + // Media keys (XF86) + 0xAD => 0x1008FF12, // AudioMute + 0xAE => 0x1008FF11, // AudioLowerVolume + 0xAF => 0x1008FF13, // AudioRaiseVolume + 0xB0 => 0x1008FF17, // AudioNext + 0xB1 => 0x1008FF16, // AudioPrev + 0xB2 => 0x1008FF15, // AudioStop + 0xB3 => 0x1008FF14, // AudioPlay + 0xB4 => 0x1008FF19, // Mail + 0xB5 => 0x1008FF32, // AudioMedia + 0xB6 => 0x1008FF40, // Launch0 + 0xB7 => 0x1008FF41, // Launch1 + // OEM punctuation (US layout, matching the labels in `keys_library`). + 0xBA => 0x003B, // ; + 0xBB => 0x003D, // = + 0xBC => 0x002C, // , + 0xBD => 0x002D, // - + 0xBE => 0x002E, // . + 0xBF => 0x002F, // slash + 0xC0 => 0x0060, // grave + 0xDB => 0x005B, // [ + 0xDC => 0x005C, // backslash + 0xDD => 0x005D, // ] + 0xDE => 0x0027, // apostrophe + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::vk_to_keysym; + use crate::keys_library::KEYS; + + #[test] + fn every_library_key_maps_to_a_keysym() { + for k in KEYS { + assert!( + vk_to_keysym(k.vk).is_some(), + "no keysym for {} (VK 0x{:02X})", + k.name, + k.vk + ); + } + } + + #[test] + fn known_mappings() { + assert_eq!(vk_to_keysym(0x41), Some(0x0061)); // A -> 'a' + assert_eq!(vk_to_keysym(0x70), Some(0xFFBE)); // F1 + assert_eq!(vk_to_keysym(0x87), Some(0xFFD5)); // F24 + assert_eq!(vk_to_keysym(0x0D), Some(0xFF0D)); // Return + assert_eq!(vk_to_keysym(0x25), Some(0xFF51)); // Left + assert_eq!(vk_to_keysym(0xAE), Some(0x1008FF11)); // Volume Down + assert_eq!(vk_to_keysym(0x69), Some(0xFFB9)); // Numpad 9 + assert_eq!(vk_to_keysym(0x07), None); // unassigned + } +} diff --git a/src/main.rs b/src/main.rs index 2a1e413..cb508fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod events; mod glue; mod keys; mod keys_library; +mod keys_vk; mod led; mod model; mod protocol; @@ -60,6 +61,19 @@ fn main() -> anyhow::Result<()> { let dark = args.iter().any(|a| a == "--dark"); // Autostart-minimized passes --hidden so we start in the tray. let start_hidden = args.iter().any(|a| a == "--hidden"); + // ...but only where there *is* a tray. Without one, starting hidden leaves a + // process with no window and no way to open one - `on_close_requested` never + // fires either, so the only exit is `kill`. Honour the flag on Windows only. + #[cfg(not(target_os = "windows"))] + let start_hidden = { + if start_hidden { + log::warn!( + "--hidden ignored: this build has no system tray, so a hidden window could \ + not be reopened. Showing the window instead." + ); + } + false + }; // Lock the main thread to an STA apartment before *any* COM-using crate // (WASAPI, winit's OleInitialize) gets a chance. @@ -101,6 +115,11 @@ fn main() -> anyhow::Result<()> { }); settings.active_preset = preset.name.clone(); + // Repair a stale autostart entry (the executable moved since it was set up). + if settings.start_with_os { + autostart::resync(settings.start_minimized); + } + let shared = Arc::new(Mutex::new(glue::Shared { settings, preset, diff --git a/src/serial.rs b/src/serial.rs index 8b41f1f..d8a40f0 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -171,16 +171,71 @@ pub fn spawn(tx: Sender) -> (RetryKicker, SerialLink) { fn friendly(err: &str) -> String { // Translate the common platform error strings to something a human can act on. + // + // serialport's `Display` is the underlying `io::Error`'s message, so on Linux + // these are the libc strings ("Permission denied (os error 13)"), not the + // Windows wording — both spellings have to be matched here. let l = err.to_lowercase(); - if l.contains("zugriff verweigert") || l.contains("access is denied") || l.contains("permissiondenied") { - "Access denied — the port is held by another program (or you lack permission)".into() - } else if l.contains("nicht gefunden") || l.contains("not found") || l.contains("nosuchdevice") { + if l.contains("zugriff verweigert") + || l.contains("access is denied") + || l.contains("permission denied") + || l.contains("permissiondenied") + || l.contains("os error 13") + { + if cfg!(target_os = "linux") { + // By far the most common Linux first-run failure: the user is not in + // the group that owns /dev/ttyUSB*, so name the fix. + "Access denied — add yourself to the 'dialout' group \ + (sudo usermod -aG dialout $USER, then log out and back in), \ + or the port is held by another program" + .into() + } else { + "Access denied — the port is held by another program (or you lack permission)".into() + } + } else if l.contains("nicht gefunden") + || l.contains("not found") + || l.contains("no such file or directory") + || l.contains("nosuchdevice") + || l.contains("os error 2") + || l.contains("os error 19") + { "Device disappeared".into() } else { err.to_string() } } +#[cfg(test)] +mod tests { + use super::friendly; + + #[test] + fn linux_permission_denied_is_translated() { + let msg = friendly("Permission denied (os error 13)"); + assert!(msg.starts_with("Access denied"), "got: {msg}"); + if cfg!(target_os = "linux") { + assert!(msg.contains("dialout"), "Linux hint missing: {msg}"); + } + } + + #[test] + fn linux_missing_device_is_translated() { + assert_eq!(friendly("No such file or directory (os error 2)"), "Device disappeared"); + assert_eq!(friendly("No such device (os error 19)"), "Device disappeared"); + } + + #[test] + fn windows_wording_still_matches() { + assert!(friendly("Access is denied.").starts_with("Access denied")); + assert!(friendly("Zugriff verweigert").starts_with("Access denied")); + } + + #[test] + fn unknown_errors_pass_through() { + assert_eq!(friendly("Broken pipe (os error 32)"), "Broken pipe (os error 32)"); + } +} + fn run_session(port_name: &str, tx: &Sender, link: &SerialLink) -> anyhow::Result<()> { let mut sp = serialport::new(port_name, BAUD) .timeout(Duration::from_millis(100)) diff --git a/ui/app.slint b/ui/app.slint index 1249073..036ef1c 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1048,6 +1048,10 @@ component CategoriesPage inherits VerticalBox { } component SettingsPage inherits VerticalBox { + // False on platforms without a system tray (currently everything but + // Windows): the tray-related rows are hidden rather than offering a toggle + // that does nothing - or, for "start minimized", strands the app headless. + in property has-tray; in-out property theme-index; in-out property accent-preset; in-out property dead-zone; @@ -1210,7 +1214,7 @@ component SettingsPage inherits VerticalBox { VerticalLayout { alignment: center; Toggle { checked <=> root.start-with-os; toggled => { root.dirty(); } } } } } - SettingRow { label: "Start minimized to tray"; + if root.has-tray: SettingRow { label: "Start minimized to tray"; description: root.start-with-os ? "" : "Enable 'Start with OS' to enable this"; HorizontalLayout { width: root.control-w; alignment: end; @@ -1218,7 +1222,7 @@ component SettingsPage inherits VerticalBox { Toggle { checked <=> root.start-minimized; enabled: root.start-with-os; toggled => { root.dirty(); } } } } } - SettingRow { label: "Minimize to system tray"; + if root.has-tray: SettingRow { label: "Minimize to system tray"; HorizontalLayout { width: root.control-w; alignment: end; VerticalLayout { alignment: center; Toggle { checked <=> root.minimize-to-tray; @@ -2107,6 +2111,8 @@ export component AppWindow inherits Window { in-out property start-with-os; in-out property start-minimized; in-out property minimize-to-tray; + // Set from Rust: true only where a tray actually exists (Windows). + in property has-tray; in-out property curve-preset-index; in property curve-editable; in-out property curve-x1; @@ -2443,6 +2449,7 @@ export component AppWindow inherits Window { start-with-os <=> root.start-with-os; start-minimized <=> root.start-minimized; minimize-to-tray <=> root.minimize-to-tray; + has-tray: root.has-tray; curve-preset-index <=> root.curve-preset-index; curve-editable: root.curve-editable; curve-x1 <=> root.curve-x1;