From 998f6cbeb3c245ad11f0875d4b94ded165f2093d Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 19 Sep 2026 14:08:47 +0200 Subject: [PATCH 1/2] feat(update): tell the user when a newer release is out One GET to the GitHub releases API per launch, compared against this build with the same version parsing the profile stamp uses. If something newer exists, a notice appears at the bottom of the sidebar and opens releases/latest in the browser. Slidr never downloads or installs anything itself: there is no updater process, nothing to elevate and nothing to roll back if the check is wrong. Failures (offline, rate-limited, unparseable answer) are silent - a machine with no network must not greet its owner with an error. The check is opt-out under Settings -> Updates, which also carries a "Check now" button and shows the running version. Co-Authored-By: Claude Opus 5 --- README.md | 7 ++++ src/glue.rs | 50 +++++++++++++++++++++++ src/main.rs | 1 + src/model.rs | 8 ++++ src/update.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++ ui/app.slint | 85 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 src/update.rs diff --git a/README.md b/README.md index dd7bfa8..854801d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ and macro board. the three setup steps, or the "Open developer portal" button under Settings → LEDs. - **Hot-plug** — automatic Arduino detection, reconnect with backoff. +- **Update check** — one request to the GitHub releases API per launch (opt-out + under Settings → Updates, plus a "Check now" button). When a newer release + exists, a notice appears in the sidebar and opens + [the releases page](https://github.com/ControlPad/App-rust/releases/latest) in + the browser. Nothing is ever downloaded or installed automatically; the + comparison uses the same version parsing as the profile stamp + ([`src/update.rs`](src/update.rs)). ## Architecture diff --git a/src/glue.rs b/src/glue.rs index 1bc01ec..c1b0dcd 100644 --- a/src/glue.rs +++ b/src/glue.rs @@ -137,6 +137,7 @@ pub fn wire( build_stamp: &'static str, ) { ui.set_build_stamp(SharedString::from(build_stamp)); + ui.set_app_version(crate::version::CURRENT.into()); // Push initial state from settings/preset → UI. { @@ -168,6 +169,21 @@ pub fn wire( }); } + // One update check per launch, unless it has been switched off. Runs on its + // own thread: an unreachable GitHub must not hold up the first frame. + if shared.lock().settings.update_check { + spawn_update_check(ui); + } + { + let weak = ui.as_weak(); + ui.on_check_for_updates(move || { + if let Some(ui) = weak.upgrade() { + spawn_update_check(&ui); + } + }); + } + ui.on_open_releases(crate::update::open_releases_page); + // Empty initial slider/button cells. push_cells(ui, &[0; NUM_SLIDERS], &[0; NUM_BUTTONS], &shared.lock().preset); ui.set_connection_msg("Searching for Slidr device…".into()); @@ -1459,6 +1475,7 @@ 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_update_check(global.update_check); 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()); @@ -1490,6 +1507,7 @@ 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.update_check = ui.get_update_check(); g.discord_client_id = ui.get_discord_client_id().to_string(); g.discord_client_secret = ui.get_discord_client_secret().to_string(); } @@ -1999,6 +2017,38 @@ fn mutate_cond_struct( Some(cfg.conditions.clone()) } +/// Run one update check on a detached thread and push the result back into the +/// UI from the event loop. A second call while one is in flight is ignored, so +/// leaning on "Check now" cannot pile up requests. +fn spawn_update_check(ui: &AppWindow) { + if ui.get_update_state() == "checking" { + return; + } + ui.set_update_state("checking".into()); + let weak = ui.as_weak(); + std::thread::spawn(move || { + let outcome = crate::update::check(); + let _ = slint::invoke_from_event_loop(move || { + let Some(ui) = weak.upgrade() else { return }; + match outcome { + crate::update::Outcome::Available(v) => { + ui.set_update_latest(v.clone().into()); + ui.set_update_state("available".into()); + log::info!("update available: {v}"); + } + crate::update::Outcome::UpToDate => { + ui.set_update_state("up-to-date".into()); + log::info!("no update: {} is current", crate::version::CURRENT); + } + crate::update::Outcome::Failed(e) => { + ui.set_update_state("failed".into()); + log::warn!("update check failed: {e}"); + } + } + }); + }); +} + fn toast(ui: &AppWindow, msg: &str) { ui.set_toast_text(msg.into()); let weak = ui.as_weak(); diff --git a/src/main.rs b/src/main.rs index 0a92f9e..73c1c73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod model; mod protocol; mod serial; mod storage; +mod update; mod version; #[cfg(target_os = "windows")] mod tray; diff --git a/src/model.rs b/src/model.rs index e075165..c7c0976 100644 --- a/src/model.rs +++ b/src/model.rs @@ -493,6 +493,13 @@ pub struct Settings { #[serde(default)] pub discord_client_secret: String, + /// Ask GitHub once per launch whether a newer release exists. Opt-out: the + /// check is a single outbound request and nothing is ever downloaded or + /// installed automatically, but it is still the app talking to the network + /// unprompted, so it can be switched off. + #[serde(default = "default_true")] + pub update_check: bool, + /// Last loaded profile name (sticky across launches). #[serde(default)] pub active_preset: String, @@ -508,6 +515,7 @@ impl Default for Settings { led_experimental: false, discord_client_id: String::new(), discord_client_secret: String::new(), + update_check: true, active_preset: "Default".into(), } } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..ba16092 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,108 @@ +//! Update check against the GitHub releases API. +//! +//! Deliberately minimal: Slidr never downloads or installs anything by itself. +//! It asks GitHub for the tag of the newest release, compares it with this +//! build, and — if there is something newer — offers a link that opens +//! `releases/latest` in the browser. That keeps the whole feature to one +//! outbound GET, with no updater process, no elevation and nothing to undo if +//! it goes wrong. +//! +//! The check is opt-out (`Settings::update_check`), runs on a detached thread so +//! a hanging network never delays the window, and treats every failure as +//! "no news": an offline machine or a rate-limited API must not produce an +//! error popup on startup. + +use std::time::Duration; + +/// Page opened when the user acts on an available update. +pub const RELEASES_URL: &str = "https://github.com/ControlPad/App-rust/releases/latest"; + +/// API endpoint backing the check. Returns the newest *non-draft, non-release- +/// candidate* release, which is exactly what `releases/latest` shows. +const API_URL: &str = "https://api.github.com/repos/ControlPad/App-rust/releases/latest"; + +/// GitHub rejects requests without a User-Agent. +const USER_AGENT: &str = concat!("Slidr/", env!("CARGO_PKG_VERSION"), " (+", "https://github.com/ControlPad/App-rust", ")"); + +/// Read `tag_name` out of a GitHub release payload and normalise it: tags are +/// published as `v0.3.0`, versions compared as `0.3.0`. +fn tag_from_payload(body: &str) -> anyhow::Result { + let v: serde_json::Value = serde_json::from_str(body)?; + let tag = v + .get("tag_name") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow::anyhow!("release payload has no tag_name"))?; + Ok(tag.trim().trim_start_matches(['v', 'V']).to_string()) +} + +/// Ask GitHub for the newest published version, as a bare version string. +fn latest_version() -> anyhow::Result { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .build(); + let body = agent + .get(API_URL) + .set("User-Agent", USER_AGENT) + .set("Accept", "application/vnd.github+json") + .call()? + .into_string()?; + tag_from_payload(&body) +} + +/// Outcome of one check. +pub enum Outcome { + /// A newer release exists; carries its version (no leading `v`). + Available(String), + /// This build is the newest published one. + UpToDate, + /// The check itself failed — offline, rate-limited, unparseable answer. + Failed(String), +} + +/// Run one check. Never panics and never blocks longer than the HTTP timeout. +pub fn check() -> Outcome { + match latest_version() { + Ok(latest) if crate::version::is_newer_than_current(&latest) => Outcome::Available(latest), + Ok(_) => Outcome::UpToDate, + Err(e) => Outcome::Failed(e.to_string()), + } +} + +/// Open the releases page in the user's browser. +pub fn open_releases_page() { + if let Err(e) = open::that(RELEASES_URL) { + log::warn!("could not open {RELEASES_URL}: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_and_strips_the_tag() { + let body = r#"{"tag_name":"v0.4.0","name":"Slidr v0.4.0"}"#; + assert_eq!(tag_from_payload(body).unwrap(), "0.4.0"); + } + + #[test] + fn accepts_a_tag_without_the_v_prefix() { + assert_eq!(tag_from_payload(r#"{"tag_name":"1.2.3"}"#).unwrap(), "1.2.3"); + } + + #[test] + fn rejects_payloads_without_a_tag() { + // GitHub answers 404 with a JSON body when a repo has no release yet; + // that must read as an error, not as "version ''". + assert!(tag_from_payload(r#"{"message":"Not Found"}"#).is_err()); + assert!(tag_from_payload("not json at all").is_err()); + } + + #[test] + fn only_a_strictly_newer_tag_is_an_update() { + // Reuses the profile-stamp comparison, so pre-release suffixes and + // partial versions behave the same in both places. + assert!(crate::version::is_newer_than_current("99.0.0")); + assert!(!crate::version::is_newer_than_current(crate::version::CURRENT)); + } +} diff --git a/ui/app.slint b/ui/app.slint index 01678c7..dd5f39a 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1079,8 +1079,15 @@ component SettingsPage inherits VerticalBox { in property discord-status; in property discord-redirect; in property build-stamp; + in property app-version; + in-out property update-check; + // "", "checking", "up-to-date", "available", "failed" + in property update-state; + in property update-latest; callback theme-changed(int); + callback check-for-updates(); + callback open-releases(); callback dirty(); callback curve-preset-changed(int); callback curve-changed(); @@ -1331,6 +1338,44 @@ component SettingsPage inherits VerticalBox { } } + Card { + VerticalBox { padding: 18px; spacing: 6px; + Text { text: "Updates"; font-weight: 700; font-family: "Inter"; + font-size: 14px; } + Rectangle { height: 6px; } + SettingRow { + label: "Check for updates on start"; + description: "Asks GitHub once per launch. Nothing is downloaded or installed automatically."; + HorizontalLayout { width: root.control-w; alignment: end; + VerticalLayout { alignment: center; + Toggle { checked <=> root.update-check; + toggled => { root.dirty(); } } } } + } + SettingRow { + label: "Version " + root.app-version; + description: root.update-state == "checking" + ? "Checking for updates…" + : root.update-state == "available" + ? "Slidr " + root.update-latest + " is available." + : root.update-state == "up-to-date" + ? "You are on the latest release." + : root.update-state == "failed" + ? "Could not reach GitHub. Check again when you are online." + : ""; + HorizontalLayout { width: root.control-w; spacing: 8px; alignment: end; + IconButton { label: "Check now"; + icon: @image-url("../assets/icons/refresh.svg"); + enabled: root.update-state != "checking"; + clicked => { root.check-for-updates(); } } + if root.update-state == "available": IconButton { + label: "Download"; + primary: true; + clicked => { root.open-releases(); } } + } + } + } + } + if root.profile-write-blocked: Rectangle { border-radius: 10px; border-width: 1px; border-color: #d3831f; background: #d3831f22; @@ -2273,6 +2318,11 @@ export component AppWindow inherits Window { in property connected; in property connection-msg; in property retry-eta-secs; + in property app-version; + in-out property update-check; + // "", "checking", "up-to-date", "available", "failed" — see `src/update.rs`. + in property update-state; + in property update-latest; in property badge-text; in property active-preset-name; in property build-stamp; @@ -2399,6 +2449,8 @@ export component AppWindow inherits Window { callback edit-line(int, int, int); callback wizard-finish(WizardResult); callback theme-changed(int); + callback check-for-updates(); + callback open-releases(); callback save-settings(); callback curve-preset-changed(int); callback curve-changed(); @@ -2521,6 +2573,33 @@ export component AppWindow inherits Window { Rectangle { vertical-stretch: 1; } + // Update notice — only present when a newer release exists, so + // the sidebar is unchanged for someone who is up to date. + // Clicking opens the releases page in the browser. + if root.update-state == "available": update-ta := TouchArea { + mouse-cursor: pointer; + height: 34px; + clicked => { root.open-releases(); } + Rectangle { + background: update-ta.has-hover + ? Theme.accent-bg : Theme.accent-bg.with-alpha(0.15); + border-radius: 8px; + border-width: 1px; border-color: Theme.accent-bg; + } + HorizontalLayout { + padding-left: root.sidebar-collapsed ? 0px : 10px; + padding-right: root.sidebar-collapsed ? 0px : 10px; + spacing: 6px; alignment: center; + Icon { source: @image-url("../assets/icons/refresh.svg"); size: 14px; + tint: update-ta.has-hover ? Theme.accent-fg : Theme.accent-bg; } + if !root.sidebar-collapsed: Text { + text: "Update " + root.update-latest; + font-size: 12px; font-family: "Inter"; font-weight: 600; + vertical-alignment: center; + color: update-ta.has-hover ? Theme.accent-fg : Palette.foreground; } + } + } + // Status pill — same vertical padding in both modes Rectangle { height: 36px; @@ -2666,6 +2745,12 @@ export component AppWindow inherits Window { curve-x2 <=> root.curve-x2; curve-y2 <=> root.curve-y2; build-stamp: root.build-stamp; + app-version: root.app-version; + update-check <=> root.update-check; + update-state: root.update-state; + update-latest: root.update-latest; + check-for-updates => { root.check-for-updates(); } + open-releases => { root.open-releases(); } theme-changed(i) => { root.theme-changed(i); } dirty => { root.save-settings(); } curve-preset-changed(i) => { root.curve-preset-changed(i); } From 12a63324ab2aafacf3954df00c92348a434b24f6 Mon Sep 17 00:00:00 2001 From: PalmarHealer Date: Sat, 19 Sep 2026 14:11:55 +0200 Subject: [PATCH 2/2] chore(release): 0.4.0, and ship the udev rule with the Linux binary The crate version is what the update check compares against the newest release tag, so it has to match the tag being cut - left at 0.3.0, every 0.4.0 install would announce an update to itself. The udev rule existed only in the repository. Someone who downloaded the Linux binary from the releases page - which is the documented way to get it - had no way to reach the file the README tells them to install, and hit a root-only serial port instead. It is now a release asset. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 4 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 +++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87881aa..27b75d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,10 @@ jobs: cargo build --release mkdir -p dist cp target/release/slidr dist/Slidr-linux-x86_64 + # Ships alongside the binary: without this rule the serial device is + # root-only on most distributions, and someone who downloaded the + # binary (rather than cloning) has no way to get at the file. + cp packaging/99-slidr.rules dist/99-slidr.rules - name: Build (Windows + interactive installer) if: matrix.target == 'windows' diff --git a/Cargo.lock b/Cargo.lock index fb0ffa4..a005880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4908,7 +4908,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slidr" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4146b31..8b6a2a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slidr" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Cross-platform Arduino-based audio/macro pad controller" license = "MIT" diff --git a/README.md b/README.md index 854801d..fcc77ba 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ cargo run --release sudo udevadm control --reload-rules && sudo udevadm trigger ``` + The same file is attached to every [release](https://github.com/ControlPad/App-rust/releases/latest) + as `99-slidr.rules`, so there is no need to clone the repository for it. + or add yourself to the port's group and log out and back in: ```sh