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