1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
//! Background "is there a newer release?" check — notification only.
//!
//! On startup we spawn a background thread that does a single HTTP
//! GET against the GitHub releases API for the configured repo
//! (`chris-mclennan/mnml`). If the response carries a `tag_name`
//! semver-newer than `CARGO_PKG_VERSION`, we stash it on a shared
//! `Arc<UpdateCheck>` that `App::tick` polls. First tick after data
//! arrives, a toast goes up telling the user how to upgrade — the
//! message adapts to how they installed:
//!
//! - `~/.cargo/bin/mnml` → `cargo install mnml-rs`
//! - `/opt/homebrew/bin/mnml` → `brew upgrade mnml`
//! - `/usr/local/bin/mnml` → `brew upgrade mnml` (Intel prefix)
//! - `/Applications/mnml.app/…` → GitHub releases URL (download DMG)
//! - Anywhere else → `git pull && cargo install --path .`
//!
//! Silently no-op in headless mode (no toast surface). No config
//! knob for now — the check is cheap (one HTTP call, 10s timeout,
//! runs on a fresh thread), and the toast fires at most once per
//! session.
//!
//! Deliberately notification-only: no in-app installer, no sudo/UAC
//! dance, no SHA256 verification. User does the upgrade themselves
//! via the familiar channel. See git history around commit
//! d015b0b if you want the full-blown installer variant back.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Repo to query — `<owner>/<name>`. Used in the GitHub API URL
/// and the human-readable release link.
pub const REPO: &str = "chris-mclennan/mnml";
/// User-Agent the GitHub API requires for unauthenticated reads.
const USER_AGENT: &str = "mnml-update-check (https://github.com/chris-mclennan/mnml)";
/// How mnml was installed on this machine. Detected once at startup
/// by looking at `current_exe()` — determines which upgrade command
/// the toast recommends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
/// `cargo install mnml-rs` — binary lives under `~/.cargo/bin/`.
Cargo,
/// Homebrew — binary lives under `/opt/homebrew/bin/`
/// (Apple Silicon) or `/usr/local/bin/` (Intel).
Homebrew,
/// Installed as `mnml.app` — binary lives inside a
/// `.app/Contents/Resources/bin/` bundle.
AppBundle,
/// Anywhere else — likely a `git pull && cargo install --path .`
/// dev build.
Source,
}
impl InstallChannel {
/// Best-guess based on the running binary's absolute path.
pub fn detect() -> Self {
let exe = std::env::current_exe().ok();
let path = exe.as_ref().and_then(|p| p.to_str()).unwrap_or("");
if path.contains("/.cargo/bin/") {
InstallChannel::Cargo
} else if path.starts_with("/opt/homebrew/") || path.starts_with("/usr/local/") {
InstallChannel::Homebrew
} else if path.contains(".app/Contents/") {
InstallChannel::AppBundle
} else {
InstallChannel::Source
}
}
/// Text the toast tells the user to run/do to upgrade. Includes
/// the version tag so the message stands on its own.
pub fn upgrade_hint(self, latest: &str) -> String {
match self {
InstallChannel::Cargo => format!("cargo install mnml-rs → v{latest}"),
InstallChannel::Homebrew => format!("brew upgrade mnml → v{latest}"),
InstallChannel::AppBundle => {
format!("download v{latest}: {}", UpdateCheck::release_url(latest))
}
InstallChannel::Source => {
format!("git pull && cargo install --path . → v{latest}")
}
}
}
}
/// Shared between the background fetch thread and the main loop.
/// Wrapped in `Arc` so it's cheap to clone the reader handle into
/// `App`.
pub struct UpdateCheck {
/// `Some("0.1.3")` once the HTTP fetch resolves and the tag is
/// strictly newer than `CARGO_PKG_VERSION`. `None` while still
/// fetching, or when we're already on latest.
pub latest_version: Mutex<Option<String>>,
/// Set true once `App::tick` has surfaced the result to the
/// user. Prevents the toast from re-firing on every tick.
pub announced: AtomicBool,
/// Where mnml was installed — determines the toast message.
pub channel: InstallChannel,
}
impl UpdateCheck {
/// Spawn the background fetch. Returns the shared handle the
/// app polls. Non-blocking — the HTTP request runs on a fresh
/// std thread so the editor never waits on it.
pub fn spawn() -> Arc<Self> {
let handle = Arc::new(Self {
latest_version: Mutex::new(None),
announced: AtomicBool::new(false),
channel: InstallChannel::detect(),
});
let bg = Arc::clone(&handle);
std::thread::spawn(move || {
if let Some(latest) = fetch_latest_tag() {
let current = env!("CARGO_PKG_VERSION");
if is_newer(&latest, current)
&& let Ok(mut slot) = bg.latest_version.lock()
{
*slot = Some(latest);
}
}
});
handle
}
/// `Some(version)` once the background fetch resolves and the
/// user hasn't been told yet. Flips the announced flag so
/// callers can fire a one-shot toast without bookkeeping.
pub fn take_pending_announcement(&self) -> Option<String> {
if self.announced.load(Ordering::Relaxed) {
return None;
}
let latest = self.latest_version.lock().ok()?.clone()?;
self.announced.store(true, Ordering::Relaxed);
Some(latest)
}
/// Human-readable release URL for the toast + copy/paste.
pub fn release_url(latest: &str) -> String {
format!("https://github.com/{REPO}/releases/tag/v{latest}")
}
}
/// Compare two semver-shaped strings. Returns true iff `remote` is
/// strictly newer than `local`. Tail segments default to 0 so "0.1"
/// < "0.1.1". Anything unparseable returns false — we'd rather skip
/// a real upgrade than announce a phantom one.
fn is_newer(remote: &str, local: &str) -> bool {
fn parts(v: &str) -> Option<(u64, u64, u64)> {
let v = v.trim_start_matches('v').split(['-', '+']).next()?;
let mut it = v.split('.').map(|s| s.parse::<u64>().ok());
let major = it.next()??;
let minor = it.next().flatten().unwrap_or(0);
let patch = it.next().flatten().unwrap_or(0);
Some((major, minor, patch))
}
match (parts(remote), parts(local)) {
(Some(r), Some(l)) => r > l,
_ => false,
}
}
fn fetch_latest_tag() -> Option<String> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let resp = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(10))
.build()
.ok()?
.get(&url)
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.text().ok()?;
let parsed: serde_json::Value = serde_json::from_str(&body).ok()?;
let tag = parsed.get("tag_name")?.as_str()?;
Some(tag.trim_start_matches('v').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_url_strips_v_prefix_consistently() {
let url = UpdateCheck::release_url("0.1.3");
assert_eq!(
url,
format!("https://github.com/{REPO}/releases/tag/v0.1.3")
);
}
#[test]
fn take_pending_announcement_is_one_shot() {
let uc = UpdateCheck {
latest_version: Mutex::new(Some("0.99.0".into())),
announced: AtomicBool::new(false),
channel: InstallChannel::Source,
};
assert_eq!(uc.take_pending_announcement().as_deref(), Some("0.99.0"));
assert_eq!(uc.take_pending_announcement(), None, "second call is no-op");
}
#[test]
fn is_newer_semver_compare() {
assert!(is_newer("0.1.4", "0.1.3"));
assert!(is_newer("0.2.0", "0.1.99"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(!is_newer("0.1.3", "0.1.4"), "same-minor downgrade");
assert!(!is_newer("0.1.3", "0.1.3"), "same version");
assert!(!is_newer("garbage", "0.1.3"), "unparseable → false");
}
#[test]
fn upgrade_hint_matches_channel() {
assert!(
InstallChannel::Cargo
.upgrade_hint("1.2.3")
.contains("cargo install")
);
assert!(
InstallChannel::Homebrew
.upgrade_hint("1.2.3")
.contains("brew upgrade")
);
assert!(
InstallChannel::AppBundle
.upgrade_hint("1.2.3")
.contains("github.com")
);
assert!(
InstallChannel::Source
.upgrade_hint("1.2.3")
.contains("git pull")
);
}
}