Skip to content

Commit 7b2ea76

Browse files
committed
feat: nudge desktop app when a newer release is available
- Query the latest GitHub release on the control panel and show a themed banner with a Download button when the running build is behind - Add `update.rs` module with `newer_release()` async check and blocking wrapper `newer_release_blocking()` that works without an install receipt, supporting the macOS app bundle that ships out-of-band - Implement pure semver comparison seam `newer_than()` to parse and compare version strings, tolerating missing "v" prefix and ignoring parse failures to prevent false nags - Integrate update check into `stitch-setup` app with `check_for_update()` that spawns a one-shot worker thread and requests repaints on success - Add `update_banner()` UI component to display available version with Download button that opens the releases page - Add `open_url()` helper for cross-platform URL opening (macOS, Windows, Linux) - Add `semver` 1.0 dependency for semantic versioning comparison - Bump version to 0.1.65
1 parent 595e7cf commit 7b2ea76

7 files changed

Lines changed: 161 additions & 4 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a576c31d949378f7729b83b21c2f4876ecf39b97
1+
9f3f83fc3e22a8257e44f6e9d9df417b96ff5e92

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.64
1+
0.1.65

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.64"
3+
version = "0.1.65"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; signs UniswapX limit orders and closes settlement auctions."
66
license = "AGPL-3.0-or-later"
@@ -36,6 +36,7 @@ alloy-rlp = "0.3"
3636
k256 = { version = "0.13", features = ["ecdsa"] }
3737
serde = { version = "1", features = ["derive"] }
3838
serde_json = "1"
39+
semver = "1"
3940
toml = "0.8"
4041
anyhow = "1"
4142
thiserror = "1"

src/bin/stitch-setup/app.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ pub struct StitchApp {
4242
pub logs: Arc<Mutex<VecDeque<String>>>,
4343
/// Textile mark shown in the header (loaded once at startup).
4444
pub icon: Option<egui::TextureHandle>,
45+
/// Newer release version if the background check found one. Shared with the
46+
/// worker thread that queries GitHub, hence the Arc/Mutex.
47+
pub available_update: Arc<Mutex<Option<String>>>,
48+
/// Guards the one-shot update check so it's spawned once, not every frame.
49+
update_check_started: bool,
4550
child: Option<Child>,
4651
}
4752

@@ -85,10 +90,30 @@ impl StitchApp {
8590
action_note: None,
8691
logs: Arc::new(Mutex::new(VecDeque::new())),
8792
icon,
93+
available_update: Arc::new(Mutex::new(None)),
94+
update_check_started: false,
8895
child: None,
8996
}
9097
}
9198

99+
/// Kick off a one-shot, best-effort "is a newer release out?" check on a
100+
/// worker thread. Safe to call every frame: it only spawns once. On success
101+
/// it stores the version and requests a repaint so the nudge appears.
102+
pub fn check_for_update(&mut self, ctx: &egui::Context) {
103+
if self.update_check_started {
104+
return;
105+
}
106+
self.update_check_started = true;
107+
let slot = self.available_update.clone();
108+
let ctx = ctx.clone();
109+
std::thread::spawn(move || {
110+
if let Some(version) = stitch_bot::update::newer_release_blocking() {
111+
*slot.lock().unwrap() = Some(version);
112+
ctx.request_repaint();
113+
}
114+
});
115+
}
116+
92117
pub fn push_log(logs: &Arc<Mutex<VecDeque<String>>>, line: String) {
93118
let mut buf = logs.lock().unwrap();
94119
if buf.len() >= MAX_LOG_LINES {

src/bin/stitch-setup/panel.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ pub fn show(app: &mut StitchApp, ui: &mut egui::Ui) {
1414
let p = Palette::current(&ctx);
1515
let running = app.status != Status::Stopped;
1616

17+
app.check_for_update(&ctx);
18+
1719
egui::Panel::top("header")
1820
.frame(panel_frame(&p, 14, 12))
1921
.show(ui, |ui| {
@@ -43,6 +45,14 @@ pub fn show(app: &mut StitchApp, ui: &mut egui::Ui) {
4345
});
4446
});
4547

48+
if let Some(latest) = app.available_update.lock().unwrap().clone() {
49+
egui::Panel::top("update-nudge")
50+
.frame(panel_frame(&p, 0, 12))
51+
.show(ui, |ui| {
52+
update_banner(ui, &p, &latest);
53+
});
54+
}
55+
4656
egui::Panel::top("controls")
4757
.frame(panel_frame(&p, 12, 12))
4858
.show(ui, |ui| {
@@ -192,6 +202,31 @@ fn status_style(p: &Palette, status: Status) -> (egui::Color32, egui::Color32, &
192202
}
193203
}
194204

205+
/// A warning-tinted strip that tells the operator a newer release is out and
206+
/// sends them to the download page. The macOS app ships out-of-band, so a
207+
/// download (not an in-place self-update) is the honest action for everyone.
208+
fn update_banner(ui: &mut egui::Ui, p: &Palette, latest: &str) {
209+
egui::Frame::new()
210+
.fill(p.warning_bg)
211+
.corner_radius(egui::CornerRadius::same(10))
212+
.inner_margin(Margin::symmetric(13, 10))
213+
.show(ui, |ui| {
214+
ui.set_min_width(ui.available_width());
215+
ui.horizontal(|ui| {
216+
ui.label(
217+
RichText::new(format!("Stitch v{latest} is available."))
218+
.color(p.warning)
219+
.strong(),
220+
);
221+
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
222+
if theme::tinted_button(ui, p.accent, "Download").clicked() {
223+
open_url(stitch_bot::update::RELEASES_PAGE);
224+
}
225+
});
226+
});
227+
});
228+
}
229+
195230
fn open_folder(dir: &std::path::Path) {
196231
#[cfg(target_os = "macos")]
197232
let _ = std::process::Command::new("open").arg(dir).spawn();
@@ -200,3 +235,12 @@ fn open_folder(dir: &std::path::Path) {
200235
#[cfg(all(unix, not(target_os = "macos")))]
201236
let _ = std::process::Command::new("xdg-open").arg(dir).spawn();
202237
}
238+
239+
fn open_url(url: &str) {
240+
#[cfg(target_os = "macos")]
241+
let _ = std::process::Command::new("open").arg(url).spawn();
242+
#[cfg(target_os = "windows")]
243+
let _ = std::process::Command::new("explorer").arg(url).spawn();
244+
#[cfg(all(unix, not(target_os = "macos")))]
245+
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
246+
}

src/update.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,89 @@ pub async fn warn_if_outdated() {
4949
);
5050
}
5151
}
52+
53+
/// Public repo the release binaries (both `stitch` and `stitch-setup`) are cut
54+
/// from. The two ship from one crate version, so a single check covers both.
55+
const RELEASE_REPO: &str = "textile-protocol/textile-stitch";
56+
57+
/// The releases page to send operators to when a self-update isn't possible
58+
/// (notably the macOS `Stitch.app`, which ships out-of-band with no updater
59+
/// receipt, so it's re-downloaded rather than patched in place).
60+
pub const RELEASES_PAGE: &str =
61+
"https://github.com/textile-protocol/textile-stitch/releases/latest";
62+
63+
#[derive(serde::Deserialize)]
64+
struct GhRelease {
65+
tag_name: String,
66+
}
67+
68+
/// Best-effort "is a newer release published?" check that does NOT need an
69+
/// install receipt, so it works for the macOS app bundle too. Returns the newer
70+
/// version string, or None when already current / offline / anything odd.
71+
pub async fn newer_release() -> Option<String> {
72+
let url = format!("https://api.github.com/repos/{RELEASE_REPO}/releases/latest");
73+
let client = reqwest::Client::builder()
74+
.user_agent(concat!("stitch/", env!("CARGO_PKG_VERSION")))
75+
.build()
76+
.ok()?;
77+
let release: GhRelease = client
78+
.get(url)
79+
.send()
80+
.await
81+
.ok()?
82+
.error_for_status()
83+
.ok()?
84+
.json()
85+
.await
86+
.ok()?;
87+
newer_than(env!("CARGO_PKG_VERSION"), &release.tag_name)
88+
}
89+
90+
/// Blocking wrapper so the synchronous GUI can run the check on a worker thread
91+
/// without threading a runtime through its own code.
92+
pub fn newer_release_blocking() -> Option<String> {
93+
tokio::runtime::Builder::new_current_thread()
94+
.enable_all()
95+
.build()
96+
.ok()?
97+
.block_on(newer_release())
98+
}
99+
100+
/// The pure comparison seam: return the normalized latest version when
101+
/// `latest_tag` (e.g. "v0.2.0") parses to a semver strictly greater than
102+
/// `current`. Any parse failure yields None, so a garbled tag can't nag.
103+
fn newer_than(current: &str, latest_tag: &str) -> Option<String> {
104+
let latest = semver::Version::parse(latest_tag.strip_prefix('v').unwrap_or(latest_tag)).ok()?;
105+
let current = semver::Version::parse(current).ok()?;
106+
(latest > current).then(|| latest.to_string())
107+
}
108+
109+
#[cfg(test)]
110+
mod tests {
111+
use super::newer_than;
112+
113+
#[test]
114+
fn flags_a_newer_tag() {
115+
assert_eq!(newer_than("0.1.0", "v0.2.0").as_deref(), Some("0.2.0"));
116+
}
117+
118+
#[test]
119+
fn tolerates_a_missing_v_prefix() {
120+
assert_eq!(newer_than("0.1.0", "0.2.0").as_deref(), Some("0.2.0"));
121+
}
122+
123+
#[test]
124+
fn ignores_the_same_version() {
125+
assert!(newer_than("0.1.0", "v0.1.0").is_none());
126+
}
127+
128+
#[test]
129+
fn ignores_an_older_tag() {
130+
assert!(newer_than("0.2.0", "v0.1.0").is_none());
131+
}
132+
133+
#[test]
134+
fn ignores_a_garbage_tag() {
135+
assert!(newer_than("0.1.0", "nightly").is_none());
136+
}
137+
}

0 commit comments

Comments
 (0)