//! The in-app updater: checks a release manifest, offers what it finds, and //! hands the installer to `tauri-plugin-updater` (signature verified against //! the key baked into tauri.conf.json). Two channels — `stable`, the latest //! GitHub release, and `nightly`, a rolling prerelease rebuilt from `main` //! by `.github/workflows/nightly.yml` — with the manifest URLs overridable //! in config (a mirror, a separate public releases repo, a Gitea). Nothing //! is ever installed without the user pressing Install: the automatic //! check only records what it found and tells the webview. //! //! PRIVATE RELEASES (the closed alpha): the repository is private, and a //! private release cannot be read without a token. With a read-only token //! saved in the Credential Manager (Settings → Updates → Access token), a //! GitHub source is resolved through the API instead of the browser URLs: //! `GET /repos/{o}/{r}/releases/tags/{tag}` for a pinned tag, and for //! `latest` a `GET /repos/{o}/{r}/releases` whose first entry that is //! neither a draft nor the rolling `nightly` wins. NOT `/releases/latest`: //! that answers only with full releases, and every release here is an //! alpha, so it 404s. Either way the `latest.json` asset is found, the //! plugin fetches THAT asset URL with //! `Authorization: Bearer` + `Accept: application/octet-stream`, and the //! manifest's own download URLs are API asset URLs too — the workflows //! rewrite them after publishing — so the installer downloads with the same //! headers. Any non-GitHub source simply gets the token as a bearer header. //! Without a token the public browser URLs are used, unchanged. //! //! Versions: a nightly is `X.Y.Z-nightly.YYYYMMDD.sha`, so on the nightly //! channel any different build is an update; on stable, newer wins, and a //! machine that was on nightly is offered the stable with the same base //! number or higher (semver would call that a downgrade and refuse). use std::sync::Mutex; use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, Url}; use tauri_plugin_updater::{Update, UpdaterExt}; use crate::config; pub const DEFAULT_STABLE: &str = "https://github.com/KeelrLeek/SoulStream/releases/latest/download/latest.json"; pub const DEFAULT_NIGHTLY: &str = "https://github.com/KeelrLeek/SoulStream/releases/download/nightly/latest.json"; /// The tag `nightly.yml` rewrites daily. Never what `latest` should resolve to. const NIGHTLY_TAG: &str = "nightly"; /// First automatic check after launch, then the interval between checks. const FIRST_CHECK: Duration = Duration::from_secs(25); const CHECK_EVERY: Duration = Duration::from_secs(6 * 60 * 60); #[derive(Default)] pub struct UpdaterState { inner: Mutex, } #[derive(Default)] struct Inner { found: Option, checking: bool, installing: bool, last_check: Option, last_error: Option, progress: Option, } #[derive(Serialize, Clone, Copy)] pub struct Progress { pub received: u64, pub total: Option, } #[derive(Serialize, Clone)] pub struct Available { pub version: String, pub notes: Option, pub date: Option, } /// What the Settings card and the toast read. #[derive(Serialize, Clone)] pub struct Status { pub version: String, pub channel: String, pub auto_check: bool, pub endpoint_stable: String, pub endpoint_nightly: String, pub default_stable: String, pub default_nightly: String, /// A read-only token for a private source is saved (never the token). pub has_token: bool, /// "github" when the current channel's source is a GitHub releases URL /// (resolved through the API when a token is set), else "raw". pub source_kind: String, pub available: Option, pub checking: bool, pub installing: bool, pub last_check: Option, pub last_error: Option, pub progress: Option, /// A dev build run from the target dir has nothing to update in place. pub dev_build: bool, } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } pub fn channel_of(cfg: &config::AppConfig) -> String { match cfg.update_channel.as_deref() { Some("nightly") => "nightly".into(), _ => "stable".into(), } } pub fn auto_check(cfg: &config::AppConfig) -> bool { cfg.update_auto_check.unwrap_or(true) } fn endpoint(cfg: &config::AppConfig, channel: &str) -> String { let pick = |v: &Option, d: &str| { v.as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or_else(|| d.to_string()) }; if channel == "nightly" { pick(&cfg.update_endpoint_nightly, DEFAULT_NIGHTLY) } else { pick(&cfg.update_endpoint_stable, DEFAULT_STABLE) } } /// A GitHub releases URL, taken apart: `github.com/{owner}/{repo}/releases/ /// latest/download/` or `…/releases/download/{tag}/`. #[derive(Debug, PartialEq, Eq)] pub struct GitHubSource { pub owner: String, pub repo: String, /// `latest`, or `tags/` — the API path segment. pub release: String, pub file: String, } pub fn github_source(url: &Url) -> Option { if url.host_str()? != "github.com" { return None; } let seg: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); match seg.as_slice() { [owner, repo, "releases", "latest", "download", file] => Some(GitHubSource { owner: owner.to_string(), repo: repo.to_string(), release: "latest".into(), file: file.to_string(), }), [owner, repo, "releases", "download", tag, file] => Some(GitHubSource { owner: owner.to_string(), repo: repo.to_string(), release: format!("tags/{tag}"), file: file.to_string(), }), _ => None, } } fn status(app: &AppHandle) -> Status { let cfg = config::load(app); let channel = channel_of(&cfg); let ep = endpoint(&cfg, &channel); let source_kind = match Url::parse(&ep).ok().and_then(|u| github_source(&u)) { Some(_) => "github", None => "raw", }; let st = app.state::(); let inner = st.inner.lock().unwrap(); Status { version: app.package_info().version.to_string(), endpoint_stable: endpoint(&cfg, "stable"), endpoint_nightly: endpoint(&cfg, "nightly"), default_stable: DEFAULT_STABLE.into(), default_nightly: DEFAULT_NIGHTLY.into(), has_token: config::load_update_token().is_some(), source_kind: source_kind.into(), auto_check: auto_check(&cfg), channel, available: inner.found.as_ref().map(|u| Available { version: u.version.clone(), notes: u.body.clone(), date: u.date.map(|d| d.unix_timestamp()), }), checking: inner.checking, installing: inner.installing, last_check: inner.last_check, last_error: inner.last_error.clone(), progress: inner.progress, dev_build: cfg!(debug_assertions), } } fn announce(app: &AppHandle) { let _ = app.emit("updater:status", status(app)); } /// One authenticated GitHub API GET, its failures already in words. async fn github_json( client: &reqwest::Client, url: &str, token: &str, user_agent: &str, ) -> Result { let resp = client .get(url) .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) .header(reqwest::header::ACCEPT, "application/vnd.github+json") .header(reqwest::header::USER_AGENT, user_agent) .header("X-GitHub-Api-Version", "2022-11-28") .send() .await .map_err(|e| format!("could not reach the GitHub API: {e}"))?; match resp.status().as_u16() { 200 => {} 401 => return Err("the access token was refused".into()), 403 => return Err("the access token cannot read that repository".into()), 404 => return Err("no release found — or the token cannot see the repository".into()), s => return Err(format!("GitHub answered {s}")), } resp.json().await.map_err(|e| e.to_string()) } /// Ask the GitHub API where a private release keeps its `latest.json`. async fn resolve_github_asset( src: &GitHubSource, token: &str, user_agent: &str, ) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .map_err(|e| e.to_string())?; let repo = format!("https://api.github.com/repos/{}/{}", src.owner, src.repo); let body = if src.release == "latest" { // `/releases/latest` answers with the newest FULL release, and every // SoulStream release so far is a prerelease — so it 404s and Stable // reports the token cannot see the repository. The list comes newest // first: take the first that is neither a draft nor the rolling // nightly, which the Nightly channel asks for by tag instead. let list = github_json(&client, &format!("{repo}/releases?per_page=30"), token, user_agent) .await?; list.as_array() .and_then(|rs| { rs.iter() .find(|r| { r["draft"].as_bool() != Some(true) && r["tag_name"].as_str() != Some(NIGHTLY_TAG) }) .cloned() }) .ok_or("no release found — or the token cannot see the repository")? } else { github_json( &client, &format!("{repo}/releases/{}", src.release), token, user_agent, ) .await? }; let assets = body["assets"].as_array().cloned().unwrap_or_default(); let asset = assets .iter() .find(|a| a["name"].as_str() == Some(src.file.as_str())) .ok_or_else(|| format!("the release has no {}", src.file))?; let url = asset["url"].as_str().ok_or("asset without a URL")?; Url::parse(url).map_err(|e| e.to_string()) } /// One check against the current channel's manifest. Records the result /// (found update, time, error) and announces it; never installs. async fn check_inner(app: &AppHandle) -> Result<(), String> { let cfg = config::load(app); let channel = channel_of(&cfg); let nightly = channel == "nightly"; let ep = endpoint(&cfg, &channel); let mut url = Url::parse(&ep).map_err(|e| format!("update source is not a URL: {e}"))?; let token = config::load_update_token(); let user_agent = format!("SoulStream/{}", app.package_info().version); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::USER_AGENT, user_agent.parse().map_err(|_| "user agent")?, ); let gh = github_source(&url); if let Some(t) = &token { headers.insert( reqwest::header::AUTHORIZATION, format!("Bearer {t}").parse().map_err(|_| "token has characters a header cannot carry")?, ); if let Some(src) = &gh { url = resolve_github_asset(src, t, &user_agent).await?; } } if gh.is_some() { // Asset URLs (the API kind the workflows write into the manifest, and // the one resolved above) hand over bytes only for this Accept; the // browser URLs do not mind it. headers.insert( reqwest::header::ACCEPT, "application/octet-stream".parse().map_err(|_| "accept")?, ); } let updater = app .updater_builder() .endpoints(vec![url]) .map_err(|e| e.to_string())? .headers(headers) .timeout(Duration::from_secs(30)) .version_comparator(move |current, release| { if nightly { // Every nightly carries its date and commit: a different // build is an update, older number or not. return release.version != current; } if release.version > current { return true; } // Back from nightly to stable: the release with the nightly's // base number, or higher, is the way home. if current.pre.as_str().starts_with("nightly") && release.version.pre.is_empty() { let base = |v: &semver::Version| (v.major, v.minor, v.patch); return base(&release.version) >= base(¤t); } false }) .build() .map_err(|e| e.to_string())?; let result = updater.check().await; let st = app.state::(); let mut inner = st.inner.lock().unwrap(); inner.last_check = Some(now()); inner.checking = false; match result { Ok(found) => { if let Some(u) = &found { log::info!("updater: {} {} available on {channel}", u.version, u.target); } else { log::info!("updater: up to date on {channel}"); } inner.found = found; inner.last_error = None; Ok(()) } Err(e) => { let mut msg = e.to_string(); if token.is_none() && gh.is_some() && msg.contains("valid release JSON") { msg.push_str(" — a private repository needs an access token (Settings → Updates)"); } log::warn!("updater: check on {channel} failed: {msg}"); inner.found = None; inner.last_error = Some(msg.clone()); Err(msg) } } } async fn run_check(app: &AppHandle) -> Result<(), String> { { let st = app.state::(); let mut inner = st.inner.lock().unwrap(); if inner.checking || inner.installing { return Ok(()); } inner.checking = true; } announce(app); let r = match check_inner(app).await { Ok(()) => Ok(()), Err(e) => { // check_inner records its own errors; a failure before the // check ran (a bad URL, the API) lands here. let st = app.state::(); let mut inner = st.inner.lock().unwrap(); inner.last_check = Some(now()); inner.found = None; inner.last_error = Some(e.clone()); Err(e) } }; { let st = app.state::(); st.inner.lock().unwrap().checking = false; } announce(app); r } /// The automatic checks: one shortly after launch, then every six hours, /// as long as the setting allows. Finding one only announces it. pub fn init(app: &AppHandle) { let app = app.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(FIRST_CHECK).await; loop { if auto_check(&config::load(&app)) { let _ = run_check(&app).await; } tokio::time::sleep(CHECK_EVERY).await; } }); } #[tauri::command] pub fn updater_status(app: AppHandle) -> Status { status(&app) } #[tauri::command] pub async fn updater_check(app: AppHandle) -> Result { run_check(&app).await?; Ok(status(&app)) } /// Channel, automatic checking, the two manifest URLs, and the access token /// (empty clears it; it lives in the Credential Manager). A channel change /// forgets whatever the old channel found. #[tauri::command] pub fn updater_set( app: AppHandle, state: State<'_, UpdaterState>, channel: Option, auto_check: Option, endpoint_stable: Option, endpoint_nightly: Option, token: Option, ) -> Status { let mut cfg = config::load(&app); if let Some(c) = channel { let c = if c == "nightly" { "nightly" } else { "stable" }; if channel_of(&cfg) != c { let mut inner = state.inner.lock().unwrap(); inner.found = None; inner.last_error = None; inner.last_check = None; } cfg.update_channel = Some(c.to_string()); } if let Some(a) = auto_check { cfg.update_auto_check = Some(a); } if let Some(e) = endpoint_stable { cfg.update_endpoint_stable = Some(e.trim().to_string()).filter(|s| !s.is_empty()); } if let Some(e) = endpoint_nightly { cfg.update_endpoint_nightly = Some(e.trim().to_string()).filter(|s| !s.is_empty()); } if let Some(t) = token { let t = t.trim(); if t.is_empty() { config::clear_update_token(); } else { config::save_update_token(t); } // A new key to the door: the last answer no longer applies. let mut inner = state.inner.lock().unwrap(); inner.last_error = None; } config::save(&app, &cfg); let s = status(&app); let _ = app.emit("updater:status", s.clone()); s } /// Download the found update and run its installer. On Windows the plugin /// starts the installer (passive mode, per tauri.conf.json) and exits this /// process, so anything after a success here never runs. #[tauri::command] pub async fn updater_install(app: AppHandle) -> Result<(), String> { let update = { let st = app.state::(); let mut inner = st.inner.lock().unwrap(); if inner.installing { return Err("already installing".into()); } let Some(u) = inner.found.clone() else { return Err("no update has been found — check first".into()); }; inner.installing = true; inner.progress = Some(Progress { received: 0, total: None }); u }; announce(&app); let progress_app = app.clone(); let mut received: u64 = 0; let mut last_emit = std::time::Instant::now(); let result = update .download_and_install( move |chunk, total| { received += chunk as u64; if last_emit.elapsed() >= Duration::from_millis(150) { last_emit = std::time::Instant::now(); let _ = progress_app.emit( "updater:progress", Progress { received, total }, ); } }, || { log::info!("updater: download complete, handing over to the installer"); }, ) .await; let st = app.state::(); let mut inner = st.inner.lock().unwrap(); inner.installing = false; inner.progress = None; match result { Ok(()) => Ok(()), Err(e) => { let msg = e.to_string(); log::warn!("updater: install failed: {msg}"); inner.last_error = Some(msg.clone()); drop(inner); announce(&app); Err(msg) } } } #[cfg(test)] mod tests { use super::*; #[test] fn github_urls_are_taken_apart() { let u = Url::parse(DEFAULT_STABLE).unwrap(); assert_eq!( github_source(&u), Some(GitHubSource { owner: "KeelrLeek".into(), repo: "SoulStream".into(), release: "latest".into(), file: "latest.json".into() }) ); let u = Url::parse(DEFAULT_NIGHTLY).unwrap(); assert_eq!(github_source(&u).unwrap().release, "tags/nightly"); let u = Url::parse("https://git.whoagland.com/Keelrleek/SoulStream/releases/download/v1/latest.json").unwrap(); assert_eq!(github_source(&u), None); let u = Url::parse("https://github.com/KeelrLeek/SoulStream/releases").unwrap(); assert_eq!(github_source(&u), None); } }