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 dd7bfa8..fcc77ba 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 @@ -116,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 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); }