Skip to main content

kimun_notes/update/
mod.rs

1//! Update awareness and (where permitted) self-update.
2//!
3//! On launch the app asks GitHub whether a newer stable `kimun-notes-v*` exists
4//! and surfaces the result; on self-update-eligible channels it can also swap
5//! the binary in place. All network and filesystem work here is **blocking** —
6//! callers run it on `tokio::task::spawn_blocking` so the TUI never stalls.
7//!
8//! Self-update is offered only on the `script` and `direct` channels; on
9//! `brew` and `cargo` a newer version is surfaced as the right upgrade command
10//! rather than an in-app binary swap.
11//! User-owned config (`update_check`) lives in `config.toml`; machine-managed
12//! state (throttle, last-known version, dismissals) lives in `update_state.toml`.
13
14mod apply;
15mod channel;
16mod github;
17mod platform;
18mod provider;
19mod state;
20
21pub use channel::InstallChannel;
22pub use provider::{LatestRelease, ReleaseProvider};
23pub use state::UpdateState;
24
25/// The active release backend. **Single switch point** for *where* releases are
26/// fetched from: implement [`ReleaseProvider`] elsewhere and return it here.
27///
28/// Scope: the trait covers release discovery and the human releases URL only.
29/// Asset *naming* — the raw-binary filename ([`platform::binary_asset_name`])
30/// and the `checksums-sha256.txt` name in [`apply()`] — is a property of this
31/// project's CI (`build.yml`), constant across providers, and is intentionally
32/// not part of the trait. A provider for a different repo layout would also
33/// adjust those.
34fn provider() -> impl ReleaseProvider {
35    github::GitHubProvider
36}
37
38/// Human-facing releases page for the active provider (shown when self-update
39/// isn't available on the current install channel).
40pub fn releases_url() -> &'static str {
41    provider().releases_url()
42}
43
44use chrono::{Duration, Utc};
45use std::path::Path;
46
47/// The version compiled into this binary.
48pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
49
50/// User-Agent sent on every GitHub request (the API rejects requests without
51/// one). Shared by the releases query and the asset downloads.
52pub(crate) const USER_AGENT: &str = concat!("kimun/", env!("CARGO_PKG_VERSION"));
53
54/// How long a check result is reused before the next launch re-queries GitHub.
55const CHECK_INTERVAL_HOURS: i64 = 24;
56
57/// Issue a GET with kimün's standard headers. Blocking.
58pub(crate) fn http_get(url: &str) -> Result<ureq::Response, UpdateError> {
59    Ok(ureq::get(url)
60        .set("User-Agent", USER_AGENT)
61        .set("Accept", "application/vnd.github+json")
62        .call()?)
63}
64
65/// The outcome of an update check, ready to drive the UI.
66#[derive(Debug, Clone)]
67pub struct UpdateStatus {
68    /// The running version.
69    pub current: String,
70    /// The newest stable version available.
71    pub latest: String,
72    /// How this binary was installed (decides notify vs self-update).
73    pub channel: InstallChannel,
74    /// Whether `latest` is newer than `current`.
75    pub update_available: bool,
76    /// Whether the user has dismissed this exact `latest` version.
77    pub dismissed: bool,
78}
79
80impl UpdateStatus {
81    /// Whether the footer/dialog should nudge the user: an update exists and was
82    /// not dismissed.
83    pub fn should_notify(&self) -> bool {
84        self.update_available && !self.dismissed
85    }
86}
87
88/// Check for an update (blocking — prefer the async [`check_now`]).
89///
90/// When `force` is false the check is throttled: if the cached result is fresh
91/// (< `CHECK_INTERVAL_HOURS`) no network call is made and the cached version
92/// is reused. `force` (manual check / `kimun update`) always queries GitHub.
93///
94/// Returns `Ok(None)` only when throttled with no cached version yet.
95pub fn check(config_dir: &Path, force: bool) -> Result<Option<UpdateStatus>, UpdateError> {
96    // Force path queries immediately — no state load needed here (status_for
97    // loads + persists it).
98    if force {
99        let release = provider().latest_stable()?;
100        return Ok(Some(status_for(config_dir, &release)));
101    }
102    let st = UpdateState::load(config_dir);
103    if st.is_stale(Utc::now(), Duration::hours(CHECK_INTERVAL_HOURS)) {
104        let release = provider().latest_stable()?;
105        Ok(Some(status_for(config_dir, &release)))
106    } else {
107        Ok(st
108            .latest_version
109            .as_deref()
110            .map(|v| build_status(config_dir, &st, v)))
111    }
112}
113
114/// Build an [`UpdateStatus`] for an already-known `version` using cached state —
115/// no network, no writes.
116fn build_status(config_dir: &Path, st: &UpdateState, version: &str) -> UpdateStatus {
117    UpdateStatus {
118        current: CURRENT_VERSION.to_string(),
119        update_available: is_newer(version, CURRENT_VERSION),
120        dismissed: st.dismissed_version.as_deref() == Some(version),
121        channel: channel::detect(config_dir),
122        latest: version.to_string(),
123    }
124}
125
126/// Compute the [`UpdateStatus`] for an already-fetched `latest` release and
127/// persist the check timestamp/version. Lets a caller that already holds a
128/// [`LatestRelease`] (the apply path) avoid a second GitHub round-trip.
129pub fn status_for(config_dir: &Path, latest: &LatestRelease) -> UpdateStatus {
130    let mut st = UpdateState::load(config_dir);
131    st.last_check = Some(Utc::now());
132    st.latest_version = Some(latest.version.clone());
133    // Best-effort persist; a write failure must not fail the status.
134    if let Err(e) = st.save(config_dir) {
135        tracing::warn!("could not save update state: {e}");
136    }
137    build_status(config_dir, &st, &latest.version)
138}
139
140/// Fetch the full latest release (with downloadable assets), needed before
141/// [`apply()`]. Blocking — prefer the async [`latest_release`].
142pub fn fetch_latest() -> Result<LatestRelease, UpdateError> {
143    provider().latest_stable()
144}
145
146/// Download, verify, and install `latest`, replacing the running binary.
147/// Blocking — prefer the async [`install`].
148///
149/// The caller must gate on [`InstallChannel::self_update_eligible`] first.
150pub fn apply(latest: &LatestRelease) -> Result<(), UpdateError> {
151    apply::self_update(latest)
152}
153
154/// Run a blocking update operation on the blocking pool, flattening the
155/// `JoinError` into [`UpdateError::Task`]. The single home for the
156/// `spawn_blocking` + join-error handling shared by every async caller.
157async fn run_blocking<T, F>(f: F) -> Result<T, UpdateError>
158where
159    F: FnOnce() -> Result<T, UpdateError> + Send + 'static,
160    T: Send + 'static,
161{
162    match tokio::task::spawn_blocking(f).await {
163        Ok(result) => result,
164        Err(e) => Err(UpdateError::Task(e.to_string())),
165    }
166}
167
168/// Async [`check`] — runs on the blocking pool so the caller's runtime is never
169/// stalled.
170pub async fn check_now(
171    config_dir: std::path::PathBuf,
172    force: bool,
173) -> Result<Option<UpdateStatus>, UpdateError> {
174    run_blocking(move || check(&config_dir, force)).await
175}
176
177/// Async [`fetch_latest`].
178pub async fn latest_release() -> Result<LatestRelease, UpdateError> {
179    run_blocking(fetch_latest).await
180}
181
182/// Async [`apply()`] — consumes `latest` so it can move onto the blocking pool.
183pub async fn install(latest: LatestRelease) -> Result<(), UpdateError> {
184    run_blocking(move || apply(&latest)).await
185}
186
187/// Record that the user dismissed `version`, suppressing the notification until
188/// a newer release appears. Writes only `update_state.toml`.
189pub fn dismiss(config_dir: &Path, version: &str) -> std::io::Result<()> {
190    let mut st = UpdateState::load(config_dir);
191    st.dismissed_version = Some(version.to_string());
192    st.save(config_dir)
193}
194
195/// Compare two `X.Y.Z` versions: is `candidate` strictly newer than `current`?
196/// Unparseable input compares as not-newer (fail safe — never nudge on garbage).
197fn is_newer(candidate: &str, current: &str) -> bool {
198    match (parse_version(candidate), parse_version(current)) {
199        (Some(c), Some(cur)) => c > cur,
200        _ => false,
201    }
202}
203
204/// Parse a plain `X.Y.Z` version into a comparable tuple. Returns `None` for
205/// anything with a pre-release/build suffix or non-numeric parts — release tags
206/// considered here are always plain stable triples.
207fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
208    let mut parts = v.split('.');
209    let major = parts.next()?.parse().ok()?;
210    let minor = parts.next()?.parse().ok()?;
211    let patch = parts.next()?.parse().ok()?;
212    if parts.next().is_some() {
213        return None;
214    }
215    Some((major, minor, patch))
216}
217
218/// Anything that can go wrong during an update check or self-update.
219#[derive(Debug)]
220pub enum UpdateError {
221    /// Network / HTTP failure talking to GitHub.
222    Http(Box<ureq::Error>),
223    /// Failed to read a response body.
224    Io(std::io::Error),
225    /// Failed to parse the releases JSON.
226    Parse(serde_json::Error),
227    /// No stable `kimun-notes-v*` release found.
228    NoRelease,
229    /// This target has no published binary to self-update to.
230    UnsupportedPlatform,
231    /// A required release asset (binary or checksums) was absent.
232    MissingAsset(String),
233    /// No checksum line for the binary in `checksums-sha256.txt`.
234    NoChecksum(String),
235    /// Downloaded binary failed checksum verification.
236    ChecksumMismatch { expected: String, actual: String },
237    /// The in-place binary swap failed.
238    Replace(std::io::Error),
239    /// The blocking update task panicked or was cancelled.
240    Task(String),
241}
242
243impl std::fmt::Display for UpdateError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            Self::Http(e) => write!(f, "network error: {e}"),
247            Self::Io(e) => write!(f, "I/O error: {e}"),
248            Self::Parse(e) => write!(f, "could not parse GitHub response: {e}"),
249            Self::NoRelease => write!(f, "no stable release found"),
250            Self::UnsupportedPlatform => {
251                write!(f, "no self-update binary is published for this platform")
252            }
253            Self::MissingAsset(name) => write!(f, "release is missing asset: {name}"),
254            Self::NoChecksum(name) => write!(f, "no checksum published for {name}"),
255            Self::ChecksumMismatch { expected, actual } => {
256                write!(f, "checksum mismatch (expected {expected}, got {actual})")
257            }
258            Self::Replace(e) => write!(f, "could not replace the running binary: {e}"),
259            Self::Task(e) => write!(f, "update task failed: {e}"),
260        }
261    }
262}
263
264impl std::error::Error for UpdateError {}
265
266impl From<ureq::Error> for UpdateError {
267    fn from(e: ureq::Error) -> Self {
268        Self::Http(Box::new(e))
269    }
270}
271
272impl From<std::io::Error> for UpdateError {
273    fn from(e: std::io::Error) -> Self {
274        Self::Io(e)
275    }
276}
277
278impl From<serde_json::Error> for UpdateError {
279    fn from(e: serde_json::Error) -> Self {
280        Self::Parse(e)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn newer_versions_compare_correctly() {
290        assert!(is_newer("0.18.0", "0.17.0"));
291        assert!(is_newer("1.0.0", "0.99.99"));
292        assert!(is_newer("0.17.1", "0.17.0"));
293        assert!(!is_newer("0.17.0", "0.17.0"));
294        assert!(!is_newer("0.16.0", "0.17.0"));
295    }
296
297    #[test]
298    fn unparseable_versions_never_nudge() {
299        assert!(!is_newer("garbage", "0.17.0"));
300        assert!(!is_newer("0.18.0-beta.1", "0.17.0"));
301        assert!(!is_newer("0.18", "0.17.0"));
302    }
303}