Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/glue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1999,6 +2017,38 @@ fn mutate_cond_struct<F: FnOnce(&mut LedCondition)>(
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();
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod model;
mod protocol;
mod serial;
mod storage;
mod update;
mod version;
#[cfg(target_os = "windows")]
mod tray;
Expand Down
8 changes: 8 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
}
}
Expand Down
108 changes: 108 additions & 0 deletions src/update.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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));
}
}
Loading
Loading