//! Update notifications (docs/updates.md). At start, and at most once a day after that, a //! background thread asks GitHub for the latest stable release (`releases/latest`; nightlies and //! release candidates are never "latest"). When it is newer than this build, an Update button //! appears in the application bar beside Report a bug; it opens a small window with the version, //! the release notes and Download, Skip this version and Remind me later. //! //! The request is the only thing sent: no identifiers, no usage data. A short timeout keeps it //! from hanging, a failed check (offline, rate limited) stays silent and is retried later, and //! development builds never check. Turning off Preferences ▸ General ▸ Check for updates //! automatically stops the automatic checks; Check now still works. //! //! The host provides the HTTP client (`Services::http_get`), so this crate stays portable and //! the browser build never checks (it updates itself through its service worker). use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError}; use egui::{Color32, FontId, RichText, text::LayoutJob}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::SolveApp; use crate::theme::Tokens; /// The repository releases come from. pub const REPO: &str = "bherbruck/solidcraft"; /// The one request an update check makes. pub const LATEST_API: &str = "https://api.github.com/repos/bherbruck/solidcraft/releases/latest"; /// Where Download goes when no package fits this installation. pub const RELEASES_PAGE: &str = "https://github.com/bherbruck/solidcraft/releases/latest"; /// Release pages and assets must be on GitHub, under this repository. const RELEASE_PREFIX: &str = "https://github.com/bherbruck/solidcraft/releases/"; /// Automatic checks: at most one a day. pub const CHECK_INTERVAL_S: f64 = 24.0 * 3600.0; /// Remind me later hides the button for three days. pub const REMIND_DELAY_S: f64 = 3.0 * 24.0 * 3600.0; /// Check now can't be pressed more often than this (GitHub allows 60 anonymous requests an hour). pub const MANUAL_COOLDOWN_S: f64 = 30.0; /// After a failed automatic check, try again after this long (an hour), not every start. pub const RETRY_AFTER_FAILURE_S: f64 = 3600.0; /// Caps on what a release response may hold (it comes from the network). const MAX_NOTES: usize = 64 * 1024; const MAX_ASSETS: usize = 400; /// The Update window's width, and its release notes' wrap width. const WINDOW_W: f32 = 460.0; const NOTES_W: f32 = WINDOW_W - 30.0; /// The Linux app ID (Flatpak, desktop files). pub const APP_ID: &str = "ai.storyteller.solidcraft"; /// Why a check failed. Automatic checks never show it; Check now does. #[derive(Clone, Debug, PartialEq)] pub enum FetchError { /// GitHub answered 403/429 with a rate limit: don't ask again for this many seconds. RateLimited { retry_after_s: u64 }, /// Offline, timed out, a bad answer, ... Failed(String), } impl std::fmt::Display for FetchError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FetchError::RateLimited { retry_after_s } => { write!(f, "GitHub's rate limit was reached; try again in {} min", retry_after_s.div_ceil(60)) } FetchError::Failed(e) => f.write_str(e), } } } /// The host's HTTP GET (HTTPS, short timeout): the body of a 200 answer, or why not. pub type HttpGet = Arc Result, FetchError> + Send + Sync>; // ---- versions --------------------------------------------------------------------------------- /// A version as SolidCraft's tags and builds spell it: `0.10.0`, `v0.11.0-rc.1`, /// `0.9.0-nightly.20261014.36.1`. pub fn parse_version(v: &str) -> Option { let v = v.trim(); semver::Version::parse(v.strip_prefix('v').unwrap_or(v)).ok() } /// What a build is compared as. A nightly is built from `main` after the release it is /// numbered from (`0.9.0-nightly.…` contains everything in 0.9.0 and more), so it counts as that /// release, not as something before it: it is offered only a strictly newer stable release. pub fn comparable(v: &semver::Version) -> semver::Version { let mut v = v.clone(); if v.pre.as_str().starts_with("nightly") { v.pre = semver::Prerelease::EMPTY; } v.build = semver::BuildMetadata::EMPTY; v } /// `latest` (a release tag) is a stable release newer than the `current` build. pub fn is_newer(latest: &str, current: &str) -> bool { let (Some(latest), Some(current)) = (parse_version(latest), parse_version(current)) else { return false }; latest.pre.is_empty() && latest > comparable(¤t) } /// This build's version: the release workflow's `SOLIDCRAFT_VERSION` (with any nightly suffix) /// when it built this binary, else the crate version. pub fn current_version() -> &'static str { option_env!("SOLIDCRAFT_VERSION").filter(|v| !v.is_empty()).unwrap_or(env!("CARGO_PKG_VERSION")) } /// The release workflow built this binary (`SOLIDCRAFT_VERSION` is set there). Anything else is a /// development build, which never checks for updates. pub fn release_build() -> bool { option_env!("SOLIDCRAFT_VERSION").is_some_and(|v| !v.is_empty()) && !cfg!(debug_assertions) } // ---- installations ---------------------------------------------------------------------------- /// How this copy of SolidCraft was installed: decides which file Download fetches and whether /// the system's package manager is the way to update. #[derive(Clone, Debug, PartialEq, Eq)] pub enum InstallKind { /// Built from source (or by something other than the release workflow): never checks. Dev, /// The browser app (updates itself). Web, /// An AppImage, at this path. AppImage(PathBuf), /// A Flatpak, with this app ID. It has no network access and Flatpak updates it: never checks. Flatpak(String), /// A Snap: from the Snap Store (`store`), or a downloaded `.snap` installed with `--dangerous`. Snap { store: bool, }, Deb, Rpm, /// The plain `.tar.gz`, unpacked somewhere. LinuxTarball, /// Installed by the `.msi` (under Program Files). WindowsMsi, /// The portable `.zip`, unpacked somewhere. WindowsPortable, /// The lone `SolidCraft--windows-x64.exe`. WindowsExe, /// `SolidCraft.app`, at this path (the bundle). MacApp(PathBuf), /// A release build in a place none of the above explains (FreeBSD, a copied binary). Other, } /// What [`detect`] looks at, so tests can describe any machine. pub struct Probe<'a> { /// `std::env::consts::OS`. pub os: &'a str, pub release_build: bool, pub env: &'a dyn Fn(&str) -> Option, /// The running executable. pub exe: Option, pub exists: &'a dyn Fn(&Path) -> bool, } pub fn detect(p: &Probe) -> InstallKind { if p.os == "unknown" || cfg!(target_arch = "wasm32") { return InstallKind::Web; } if !p.release_build { return InstallKind::Dev; } let env = |k: &str| (p.env)(k).filter(|v| !v.trim().is_empty()); let Some(exe) = p.exe.clone() else { return InstallKind::Other }; match p.os { "linux" => { if let Some(id) = env("FLATPAK_ID") { return InstallKind::Flatpak(id); } if env("SNAP").is_some() && env("SNAP_NAME").is_none_or(|n| n.starts_with("solidcraft")) { // Locally installed (unasserted) snaps have revisions x1, x2, … return InstallKind::Snap { store: !env("SNAP_REVISION").is_some_and(|r| r.starts_with('x')) }; } if let Some(image) = env("APPIMAGE") { return InstallKind::AppImage(PathBuf::from(image)); } if exe.starts_with("/usr") { // nfpm installs both packages under /usr; dpkg keeps a file list per package. if (p.exists)(Path::new("/var/lib/dpkg/info/solidcraft.list")) { return InstallKind::Deb; } if (p.exists)(Path::new("/var/lib/rpm")) || (p.exists)(Path::new("/usr/lib/sysimage/rpm")) { return InstallKind::Rpm; } return InstallKind::Other; } InstallKind::LinuxTarball } "windows" => { let under = |k: &str| env(k).is_some_and(|dir| starts_with_ci(&exe, Path::new(&dir))); if under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { return InstallKind::WindowsMsi; } if exe.parent().is_some_and(|d| (p.exists)(&d.join("solidcraft-cli.exe"))) { return InstallKind::WindowsPortable; } InstallKind::WindowsExe } "macos" => app_bundle(&exe).map_or(InstallKind::Other, InstallKind::MacApp), _ => InstallKind::Other, } } /// `path` is inside `dir` (Windows paths: case-insensitive). fn starts_with_ci(path: &Path, dir: &Path) -> bool { let (p, d) = (path.to_string_lossy().to_lowercase().replace('/', "\\"), dir.to_string_lossy().to_lowercase().replace('/', "\\")); let d = d.trim_end_matches('\\'); !d.is_empty() && p.strip_prefix(d).is_some_and(|rest| rest.starts_with('\\')) } /// The `.app` bundle an executable at `…/X.app/Contents/MacOS/x` belongs to. fn app_bundle(exe: &Path) -> Option { let macos = exe.parent()?; let contents = macos.parent()?; let bundle = contents.parent()?; (macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" && bundle.extension()? == "app").then(|| bundle.to_path_buf()) } /// This machine's installation, looked up once. pub fn this_install() -> InstallKind { #[cfg(target_arch = "wasm32")] { InstallKind::Web } #[cfg(not(target_arch = "wasm32"))] { let env = |k: &str| std::env::var(k).ok(); let exists = |p: &Path| p.exists(); detect(&Probe { os: std::env::consts::OS, release_build: release_build(), env: &env, exe: std::env::current_exe().ok(), exists: &exists }) } } impl InstallKind { /// The package manager updates it: Download isn't the way (the window shows the command). pub fn system_managed(&self) -> bool { matches!(self, InstallKind::Flatpak(_) | InstallKind::Snap { store: true }) } /// A short name for Preferences and docs. pub fn label(&self) -> &'static str { match self { InstallKind::Dev => "development build", InstallKind::Web => "browser app", InstallKind::AppImage(_) => "AppImage", InstallKind::Flatpak(_) => "Flatpak", InstallKind::Snap { store: true } => "Snap", InstallKind::Snap { store: false } => "Snap (installed from a file)", InstallKind::Deb => ".deb package", InstallKind::Rpm => ".rpm package", InstallKind::LinuxTarball => ".tar.gz", InstallKind::WindowsMsi => "Windows installer (.msi)", InstallKind::WindowsPortable => "portable .zip", InstallKind::WindowsExe => "standalone .exe", InstallKind::MacApp(_) => "macOS app", InstallKind::Other => "other", } } /// Release builds check; development builds, the browser app and the Flatpak (no network /// permission; Flatpak updates it) don't. pub fn checks(&self) -> bool { !matches!(self, InstallKind::Dev | InstallKind::Web | InstallKind::Flatpak(_)) } /// The command that updates a package-managed installation, if there is one. pub fn update_command(&self, asset: Option<&str>) -> Option { match self { InstallKind::Flatpak(id) => Some(format!("flatpak update {id}")), InstallKind::Snap { store: true } => Some("sudo snap refresh solidcraft".into()), InstallKind::Snap { store: false } => { Some(format!("sudo snap install --dangerous ./{}", asset.unwrap_or("solidcraft-linux-x86_64.snap"))) } InstallKind::Deb => Some(format!("sudo apt install ./{}", asset.unwrap_or("solidcraft-linux-x86_64.deb"))), InstallKind::Rpm => Some(format!("sudo dnf install ./{}", asset.unwrap_or("solidcraft-linux-x86_64.rpm"))), _ => None, } } } /// The release asset that installs or replaces this installation, by its fixed name /// (docs/releasing.md: `solidcraft-windows-x64.msi`, `solidcraft-linux-x86_64.AppImage`, …), or /// the versioned name for the packages without one. `arch` is `std::env::consts::ARCH`. pub fn asset_name(kind: &InstallKind, arch: &str, version: &str) -> Option { let linux = match arch { "x86_64" | "aarch64" => Some(arch), _ => None, }; let windows = match arch { "x86_64" => Some("x64"), "aarch64" => Some("arm64"), "x86" => Some("x86"), _ => None, }; let version = version.trim_start_matches('v'); Some(match kind { InstallKind::AppImage(_) => format!("solidcraft-linux-{}.AppImage", linux?), InstallKind::Flatpak(_) => format!("solidcraft-linux-{}.flatpak", linux?), InstallKind::Snap { .. } => format!("solidcraft-linux-{}.snap", linux?), InstallKind::Deb => format!("solidcraft-linux-{}.deb", linux?), InstallKind::Rpm => format!("solidcraft-linux-{}.rpm", linux?), InstallKind::LinuxTarball => format!("solidcraft-{version}-linux-{}.tar.gz", linux?), InstallKind::WindowsMsi => format!("solidcraft-windows-{}.msi", windows?), InstallKind::WindowsPortable => format!("solidcraft-windows-{}-portable.zip", windows?), // Only x64 has the lone .exe; elsewhere the portable zip is the same thing. InstallKind::WindowsExe if windows == Some("x64") => format!("SolidCraft-{version}-windows-x64.exe"), InstallKind::WindowsExe => format!("solidcraft-windows-{}-portable.zip", windows?), InstallKind::MacApp(_) => "solidcraft-macos-universal.dmg".into(), InstallKind::Dev | InstallKind::Web | InstallKind::Other => return None, }) } // ---- releases --------------------------------------------------------------------------------- #[derive(Clone, Debug, PartialEq)] pub struct Asset { pub name: String, pub url: String, pub size: u64, } /// The parts of a GitHub release an update needs. #[derive(Clone, Debug, PartialEq)] pub struct Release { pub tag: String, pub name: String, /// Release notes (Markdown). pub notes: String, /// The release's page. pub page: String, pub assets: Vec, } impl Release { /// A `releases/latest` answer. Drafts and prereleases are refused (GitHub never returns them /// there, but the answer comes from the network). pub fn parse(json: &[u8]) -> Result { let v: Value = serde_json::from_slice(json).map_err(|e| FetchError::Failed(format!("unexpected answer from GitHub: {e}")))?; let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); if v.get("draft").and_then(Value::as_bool).unwrap_or(false) || v.get("prerelease").and_then(Value::as_bool).unwrap_or(false) { return Err(FetchError::Failed("the latest release is a prerelease".into())); } let tag = s("tag_name"); if parse_version(&tag).is_none() { return Err(FetchError::Failed(format!("the latest release's tag `{}` isn't a version", tag.chars().take(40).collect::()))); } let mut notes = s("body"); if notes.len() > MAX_NOTES { let mut cut = MAX_NOTES; while !notes.is_char_boundary(cut) { cut -= 1; } notes.truncate(cut); notes.push_str("\n\n…"); } let page = Some(s("html_url")).filter(|u| u.starts_with(RELEASE_PREFIX)).unwrap_or_else(|| RELEASES_PAGE.to_string()); let assets = v .get("assets") .and_then(Value::as_array) .into_iter() .flatten() .take(MAX_ASSETS) .filter_map(|a| { let name = a.get("name")?.as_str()?.to_string(); let url = a.get("browser_download_url")?.as_str()?.to_string(); url.starts_with(RELEASE_PREFIX).then(|| Asset { name, url, size: a.get("size").and_then(Value::as_u64).unwrap_or(0) }) }) .collect(); Ok(Release { name: Some(s("name")).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("SolidCraft {tag}")), tag, notes, page, assets, }) } /// The version without the `v`. pub fn version(&self) -> &str { self.tag.trim_start_matches('v') } /// The asset that fits `kind` on `arch`, if this release has it. pub fn asset_for(&self, kind: &InstallKind, arch: &str) -> Option<&Asset> { let name = asset_name(kind, arch, self.version())?; self.assets.iter().find(|a| a.name == name) } /// What Download opens: the fitting file, else the release page. Package-managed /// installations get the page (their package manager does the update). pub fn download_url(&self, kind: &InstallKind, arch: &str) -> &str { if kind.system_managed() { return &self.page; } self.asset_for(kind, arch).map_or(self.page.as_str(), |a| a.url.as_str()) } } // ---- what is remembered ----------------------------------------------------------------------- /// Update bookkeeping, kept with the preferences (not design data). Times are Unix seconds. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Memo { /// When the last check finished (successfully or not). pub last_check: f64, /// The next automatic check: a day after a successful one, an hour after a failed one. pub next_check: f64, /// GitHub's rate limit was reached: no request at all before this, not even Check now. pub rate_limited_until: f64, /// Skip this version: its button never shows (a later version's does). pub skipped: String, /// Remind me later: no button before this time. pub remind_after: f64, } impl Memo { /// An automatic check is due (a clock set back by more than a day counts as due). pub fn due(&self, now: f64) -> bool { now >= self.rate_limited_until && (now >= self.next_check || self.next_check - now > CHECK_INTERVAL_S) } /// The Update button shows for `release`. pub fn offers(&self, release: &Release, current: &str, now: f64) -> bool { is_newer(&release.tag, current) && !same_version(&self.skipped, &release.tag) && now >= self.remind_after } pub fn skip(&mut self, release: &Release) { self.skipped = release.version().to_string(); } pub fn remind_later(&mut self, now: f64) { self.remind_after = now + REMIND_DELAY_S; } /// A check finished: the next one is a day later after a success, an hour after a failure, /// and after a rate limit as long as GitHub said (at least a minute, at most a day). pub fn checked(&mut self, now: f64, result: &Result) { self.last_check = now; self.next_check = now + match result { Ok(_) => CHECK_INTERVAL_S, Err(FetchError::Failed(_)) => RETRY_AFTER_FAILURE_S, Err(FetchError::RateLimited { retry_after_s }) => (*retry_after_s as f64).clamp(60.0, CHECK_INTERVAL_S), }; if let Err(FetchError::RateLimited { .. }) = result { self.rate_limited_until = self.next_check; } } } fn same_version(a: &str, b: &str) -> bool { !a.is_empty() && a.trim_start_matches('v') == b.trim_start_matches('v') } /// Now, in Unix seconds (works in the browser too). pub fn unix_now() -> f64 { web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64()) } // ---- the running state ------------------------------------------------------------------------ #[derive(Clone, Debug, Default, PartialEq)] pub enum Status { #[default] Idle, Checking, UpToDate, Failed(String), } #[derive(Default)] pub struct Updater { /// The installation, looked up at the first frame. pub kind: Option, pub status: Status, /// The newest release found (whether or not it is offered). pub release: Option, /// The Update window is open. pub open: bool, /// The check in flight, and whether Check now started it. rx: Option<(Receiver>, bool)>, started: bool, /// When Check now was last pressed (Unix seconds). last_manual: f64, /// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots. feed: Option, /// The one-click update in progress (`install`). job: Option<(Receiver>, Arc)>, /// The last one-click update's result. pub installed: Option>, /// A program for the host to start as SolidCraft exits (restart, installer). launch: Option<(PathBuf, Vec)>, } impl Updater { /// The installation (looked up on first use). pub fn kind(&mut self) -> &InstallKind { self.kind.get_or_insert_with(this_install) } } /// The Update button shows. pub fn offered(app: &SolveApp) -> Option<&Release> { let r = app.updater.release.as_ref()?; app.preferences.update.offers(r, current_version(), unix_now()).then_some(r) } /// Checks may run here: a release build with the host's HTTP client, or a test feed. pub fn available(app: &mut SolveApp) -> bool { app.updater.feed.is_some() || (app.services.http_get.is_some() && app.updater.kind().checks()) } /// Per frame: the first frame starts a due automatic check; a finished check is collected. pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { poll_install(app); // Quitting to restart or install was cancelled (a "save changes?" answered Cancel). if app.updater.launch.is_some() && app.docs.quitting.is_none() && !app.quit_requested { app.updater.launch = None; } if !app.updater.started { app.updater.started = true; #[cfg(not(target_arch = "wasm32"))] { install::clean_up_after_update(std::env::current_exe().ok().as_deref()); if std::env::var("SOLIDCRAFT_UPDATE_CHECK").is_ok_and(|v| v == "0") { return; } app.updater.feed = std::env::var_os("SOLIDCRAFT_UPDATE_FEED").map(PathBuf::from); } } poll(app); let now = unix_now(); if app.updater.rx.is_none() && app.preferences.check_updates && app.preferences.update.due(now) && available(app) { start(app, ctx, false); } } /// Check now (Preferences): ignores the daily interval, not GitHub's rate limit. pub fn check_now(app: &mut SolveApp, ctx: &egui::Context) { let now = unix_now(); if app.updater.rx.is_some() || now - app.updater.last_manual < MANUAL_COOLDOWN_S || !available(app) { return; } if now < app.preferences.update.rate_limited_until { let wait = (app.preferences.update.rate_limited_until - now).max(0.0) as u64; app.updater.status = Status::Failed(FetchError::RateLimited { retry_after_s: wait }.to_string()); return; } app.updater.last_manual = now; start(app, ctx, true); } fn start(app: &mut SolveApp, ctx: &egui::Context, manual: bool) { let (tx, rx) = std::sync::mpsc::channel(); app.updater.status = Status::Checking; app.updater.rx = Some((rx, manual)); let feed = app.updater.feed.clone(); let http = app.services.http_get.clone(); let fetch = move || -> Result { let body = match (&feed, &http) { (Some(path), _) => std::fs::read(path).map_err(|e| FetchError::Failed(format!("{}: {e}", path.display())))?, (None, Some(get)) => get(LATEST_API)?, (None, None) => return Err(FetchError::Failed("no network client".into())), }; Release::parse(&body) }; #[cfg(not(target_arch = "wasm32"))] { let ctx = ctx.clone(); let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || { let _ = tx.send(fetch()); ctx.request_repaint(); }); if spawned.is_err() { app.updater.rx = None; app.updater.status = Status::Idle; } } #[cfg(target_arch = "wasm32")] { let _ = (ctx, tx, fetch); app.updater.rx = None; app.updater.status = Status::Idle; } } fn poll(app: &mut SolveApp) { let Some((rx, manual)) = &app.updater.rx else { return }; let manual = *manual; let result = match rx.try_recv() { Ok(r) => r, Err(TryRecvError::Empty) => return, Err(TryRecvError::Disconnected) => Err(FetchError::Failed("the check stopped".into())), }; app.updater.rx = None; app.preferences.update.checked(unix_now(), &result); match result { Ok(release) => { let newer = is_newer(&release.tag, current_version()); app.updater.status = if newer { Status::Idle } else { Status::UpToDate }; if newer && manual { // Asked for: show it even if it was skipped or put off. app.preferences.update.skipped.clear(); app.preferences.update.remind_after = 0.0; app.updater.open = true; } app.updater.release = Some(release); } Err(e) => { log::info!("update check: {e}"); app.updater.status = if manual { Status::Failed(e.to_string()) } else { Status::Idle }; } } } // ---- one-click updates ------------------------------------------------------------------------ /// How this installation updates in place, when the host can download (None: Download only). pub fn install_method(app: &mut SolveApp) -> Option { if app.services.http_get.is_none() || app.services.http_download.is_none() { return None; } #[cfg(not(target_arch = "wasm32"))] { let exe = std::env::current_exe().ok(); install::method(app.updater.kind(), exe.as_deref()) } #[cfg(target_arch = "wasm32")] { None } } /// Download, verify and apply the release found (Update now). pub fn start_install(app: &mut SolveApp, ctx: &egui::Context) { let (Some(release), Some(method), Some(get), Some(download)) = (app.updater.release.clone(), install_method(app), app.services.http_get.clone(), app.services.http_download.clone()) else { return; }; if app.updater.job.is_some() { return; } let kind = app.updater.kind().clone(); let progress = Arc::new(install::Progress::default()); let (tx, rx) = std::sync::mpsc::channel(); app.updater.installed = None; app.updater.job = Some((rx, progress.clone())); #[cfg(not(target_arch = "wasm32"))] { let ctx = ctx.clone(); let staging = std::env::temp_dir().join("solidcraft-update"); let net = install::Net { get, download }; let spawned = std::thread::Builder::new().name("update-install".into()).spawn(move || { let result = install::run(&release, &kind, &method, std::env::consts::ARCH, &net, &staging, &progress); let _ = tx.send(result); ctx.request_repaint(); }); if let Err(e) = spawned { app.updater.job = None; app.updater.installed = Some(Err(e.to_string())); } } #[cfg(target_arch = "wasm32")] { let _ = (ctx, tx, release, method, get, download, kind); app.updater.job = None; } } fn poll_install(app: &mut SolveApp) { let Some((rx, _)) = &app.updater.job else { return }; let result = match rx.try_recv() { Ok(r) => r, Err(TryRecvError::Empty) => return, Err(TryRecvError::Disconnected) => Err("the update stopped".into()), }; app.updater.job = None; match &result { Ok(install::Outcome::Installer(program, args)) => { // The installer replaces this program: start it once SolidCraft has closed. app.updater.launch = Some((program.clone(), args.clone())); crate::documents::request_quit(app); } Ok(install::Outcome::Opened(dmg)) => { #[cfg(not(target_arch = "wasm32"))] { let _ = std::process::Command::new("open").arg(dmg).spawn(); } let _ = dmg; } Ok(install::Outcome::Restart(_)) => {} Err(e) => log::warn!("update: {e}"), } app.updater.installed = Some(result); } /// Restart now: close (asking about unsaved designs), then start the new version. pub fn restart(app: &mut SolveApp) { if let Some(Ok(install::Outcome::Restart(program))) = &app.updater.installed { app.updater.launch = Some((program.clone(), Vec::new())); crate::documents::request_quit(app); } } /// For the host, as SolidCraft exits: the program to start (the new version, an installer). pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec)> { app.updater.launch.take() } // ---- user interface --------------------------------------------------------------------------- /// The application bar's Update button, right of `right`; returns its rect (None: not shown). pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, right: f32, top: f32, labeled: bool) -> Option { offered(app)?; let t = Tokens::get(); let text = crate::i18n::tr("update.button"); let width = if labeled { 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), Color32::WHITE).size().x) + 10.0 } else { 28.0 }; let rect = egui::Rect::from_min_size(egui::pos2(right - width, top), egui::vec2(width, 26.0)); let response = ui.interact(rect, ui.id().with("sc_update_button"), egui::Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); crate::scenario::publish_handle("titlebar:update", rect.center()); let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; ui.painter().rect_filled(rect, 4.0, fill); ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), egui::vec2(18.0, 18.0)); crate::icons::paint(ui.painter(), icon, "update", t.accent, t.accent, t.app_bar); if labeled { ui.painter().text( egui::pos2(rect.left() + 29.0, rect.center().y), egui::Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text, ); } let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default(); if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() { app.updater.open = !app.updater.open; } Some(rect) } /// The Update window. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if !app.updater.open { return; } let Some(release) = app.updater.release.clone() else { app.updater.open = false; return; }; let kind = app.updater.kind().clone(); let install = install_method(app); let arch = std::env::consts::ARCH; let t = Tokens::get(); let mut open = true; let mut action = None; let shown = crate::frame::window(ctx, crate::i18n::tr("update.title"), crate::frame::Width::Wide) .id(egui::Id::new("sc_update")) .open(&mut open) .resizable(false) .show(ctx, |ui| { ui.set_width(WINDOW_W); ui.set_max_width(WINDOW_W); ui.label(RichText::new(crate::i18n::format("update.available", &[("version", release.version())])).strong().size(15.0)); ui.label(RichText::new(crate::i18n::format("update.you_have", &[("version", current_version())])).color(t.text_dim)); ui.add_space(6.0); egui::Frame::group(ui.style()).show(ui, |ui| { ui.set_width(ui.available_width()); ui.set_max_width(NOTES_W + 12.0); egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, true]).show(ui, |ui| { if release.notes.trim().is_empty() { ui.label(RichText::new(crate::i18n::tr("update.no_notes")).color(t.text_dim)); } else { markdown(ui, &release.notes, &t); } }); }); ui.add_space(6.0); let asset = release.asset_for(&kind, arch).map(|a| a.name.clone()); if let Some(cmd) = kind.update_command(asset.as_deref()) { let how = match kind { InstallKind::Deb | InstallKind::Rpm | InstallKind::Snap { store: false } => "update.package_hint", _ => "update.system_hint", }; ui.add(egui::Label::new(crate::i18n::format(how, &[("kind", kind.label())])).wrap()); ui.horizontal(|ui| { ui.add(egui::Label::new(RichText::new(&cmd).monospace()).selectable(true)); if ui.small_button(crate::i18n::tr("update.copy")).clicked() { ui.ctx().copy_text(cmd.clone()); } }); ui.add_space(4.0); } else if let Some(name) = asset.as_ref().filter(|_| app.updater.installed.as_ref().is_none_or(|r| r.is_err())) { ui.label(RichText::new(crate::i18n::format("update.file", &[("file", name)])).size(11.0).color(t.text_dim)); } if let Some((_, progress)) = &app.updater.job { let done = progress.done.load(std::sync::atomic::Ordering::Relaxed); let total = progress.total.load(std::sync::atomic::Ordering::Relaxed).max(1); let mb = |b: u64| format!("{:.1}", b as f64 / 1_048_576.0); ui.add( egui::ProgressBar::new((done as f32 / total as f32).clamp(0.0, 1.0)) .text(crate::i18n::format("update.downloading", &[("done", &mb(done)), ("total", &mb(total))])), ); if ui.button(crate::i18n::tr("ui.cancel")).clicked() { progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); } ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); return; } match &app.updater.installed { Some(Ok(install::Outcome::Restart(_))) => { ui.label(RichText::new(crate::i18n::format("update.installed", &[("version", release.version())])).strong()); ui.horizontal(|ui| { let r = ui.add(egui::Button::new(RichText::new(crate::i18n::tr("update.restart")).strong()).fill(t.accent)); crate::scenario::publish_rect("update:restart", r.rect); if r.clicked() { action = Some("restart"); } if ui.button(crate::i18n::tr("update.restart_later")).clicked() { action = Some("close"); } }); return; } Some(Ok(install::Outcome::Installer(..))) => { ui.label(crate::i18n::tr("update.installer_started")); return; } Some(Ok(install::Outcome::Opened(_))) => { ui.add(egui::Label::new(crate::i18n::tr("update.dmg_opened")).wrap()); return; } Some(Err(e)) => { ui.add(egui::Label::new(RichText::new(crate::i18n::format("update.install_failed", &[("error", e)])).color(t.error)).wrap()); } None => {} } ui.horizontal(|ui| { let one_click = install.is_some() && !matches!(app.updater.installed, Some(Err(_))); if let Some(m) = install.as_ref().filter(|_| one_click) { let label = match m { install::Method::Msi => crate::i18n::tr("update.install_msi"), install::Method::Dmg => crate::i18n::tr("update.download_open"), _ => crate::i18n::tr("update.update_now"), }; let r = ui.add(egui::Button::new(RichText::new(label).strong()).fill(t.accent)); crate::scenario::publish_rect("update:install", r.rect); if r.clicked() { action = Some("install"); } } let download = if kind.system_managed() { crate::i18n::tr("update.release_page") } else { crate::i18n::tr("update.download") }; let r = if one_click { ui.button(download) } else { ui.add(egui::Button::new(RichText::new(download).strong()).fill(t.accent)) }; crate::scenario::publish_rect("update:download", r.rect); if r.clicked() { action = Some("download"); } if ui.button(crate::i18n::tr("update.skip")).clicked() { action = Some("skip"); } if ui.button(crate::i18n::tr("update.later")).clicked() { action = Some("later"); } }); }); if let Some(w) = &shown { crate::scenario::publish_rect("update:window", w.response.rect); } let now = unix_now(); match action { Some("download") => { let url = release.download_url(&kind, arch).to_string(); ctx.open_url(egui::OpenUrl::new_tab(url)); app.updater.open = false; } Some("skip") => { app.preferences.update.skip(&release); app.updater.open = false; } Some("later") => { app.preferences.update.remind_later(now); app.updater.open = false; } Some("install") => start_install(app, ctx), Some("restart") => restart(app), Some("close") => app.updater.open = false, _ if !open => app.updater.open = false, _ => {} } } /// Preferences ▸ General: the automatic check and Check now. pub fn prefs_rows(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let can = available(app); let kind = app.updater.kind().clone(); ui.label(crate::i18n::tr("update.prefs_label")); ui.add_enabled(can, egui::Checkbox::new(&mut app.preferences.check_updates, crate::i18n::tr("update.check_automatically"))); ui.end_row(); ui.label(""); ui.horizontal(|ui| { let busy = app.updater.status == Status::Checking; let r = ui.add_enabled(can && !busy, egui::Button::new(crate::i18n::tr("update.check_now"))); crate::scenario::publish_rect("prefs:check_now", r.rect); if r.clicked() { let ctx = ui.ctx().clone(); check_now(app, &ctx); } let status = match (&app.updater.status, offered_or_newer(app)) { _ if !can => String::new(), (Status::Checking, _) => crate::i18n::tr("update.checking").to_string(), (Status::Failed(e), _) => crate::i18n::format("update.failed", &[("error", e)]), (_, Some(v)) => crate::i18n::format("update.available", &[("version", &v)]), (Status::UpToDate, None) => crate::i18n::tr("update.up_to_date").to_string(), (Status::Idle, None) => String::new(), }; if !status.is_empty() { ui.label(RichText::new(status).color(t.text_dim)); } }); ui.end_row(); ui.label(""); let note = if can { crate::i18n::format("update.prefs_note", &[("version", current_version()), ("kind", kind.label())]) } else if let InstallKind::Flatpak(id) = &kind { crate::i18n::format("update.prefs_flatpak", &[("version", current_version()), ("command", &format!("flatpak update {id}"))]) } else { crate::i18n::format("update.prefs_dev", &[("version", current_version())]) }; ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap()); ui.end_row(); if let Some(v) = offered_or_newer(app) && offered(app).is_none() && ui.link(crate::i18n::format("update.show", &[("version", &v)])).clicked() { app.updater.open = true; } } /// The newer version found, if any (offered or skipped). fn offered_or_newer(app: &SolveApp) -> Option { app.updater.release.as_ref().filter(|r| is_newer(&r.tag, current_version())).map(|r| r.version().to_string()) } // ---- release notes ---------------------------------------------------------------------------- /// A span of inline Markdown. #[derive(Clone, Debug, PartialEq)] pub enum Span { Text(String), Strong(String), Code(String), /// A link's text (the target is dropped: the release page has them). Link(String), } /// A block of the release notes. #[derive(Clone, Debug, PartialEq)] pub enum Block { Heading(Vec), Paragraph(Vec), /// A list item, at a nesting depth. Item(usize, Vec), } /// The Markdown GitHub release notes use, enough to read them: headings, paragraphs (soft line /// breaks joined), `-`/`*` lists, `**strong**`, `` `code` ``, `[links](…)` and bare pull-request /// URLs (shown as `#123`). Anything else shows as written. pub fn parse_markdown(src: &str) -> Vec { let mut blocks = Vec::new(); let mut para = String::new(); let mut item: Option<(usize, String)> = None; let flush = |blocks: &mut Vec, para: &mut String, item: &mut Option<(usize, String)>| { if !para.trim().is_empty() { blocks.push(Block::Paragraph(inline(para.trim()))); } para.clear(); if let Some((depth, text)) = item.take() { blocks.push(Block::Item(depth, inline(text.trim()))); } }; for line in src.lines() { let trimmed = line.trim_start(); let indent = line.len() - trimmed.len(); if trimmed.is_empty() { flush(&mut blocks, &mut para, &mut item); } else if let Some(h) = trimmed.strip_prefix('#') { flush(&mut blocks, &mut para, &mut item); blocks.push(Block::Heading(inline(h.trim_start_matches('#').trim()))); } else if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).or_else(|| trimmed.strip_prefix("+ ")) { flush(&mut blocks, &mut para, &mut item); item = Some((indent / 2, rest.to_string())); } else if let Some((_, text)) = &mut item { text.push(' '); text.push_str(trimmed); } else { if !para.is_empty() { para.push(' '); } para.push_str(trimmed); } } flush(&mut blocks, &mut para, &mut item); blocks } /// Inline spans of one block's text. pub fn inline(text: &str) -> Vec { let mut out: Vec = Vec::new(); let mut plain = String::new(); let mut rest = text; let push_plain = |out: &mut Vec, plain: &mut String| { if !plain.is_empty() { out.push(Span::Text(std::mem::take(plain))); } }; while let Some(c) = rest.chars().next() { if let Some(after) = rest.strip_prefix("**") && let Some(end) = after.find("**") && end > 0 { push_plain(&mut out, &mut plain); let inner = after.get(..end).unwrap_or_default(); out.push(Span::Strong(strip_links(inner))); rest = after.get(end + 2..).unwrap_or_default(); } else if let Some(after) = rest.strip_prefix('`') && let Some(end) = after.find('`') { push_plain(&mut out, &mut plain); out.push(Span::Code(after.get(..end).unwrap_or_default().to_string())); rest = after.get(end + 1..).unwrap_or_default(); } else if let Some(after) = rest.strip_prefix('[') && let Some(close) = after.find("](") && let Some(end) = after.get(close + 2..).and_then(|s| s.find(')')) { push_plain(&mut out, &mut plain); out.push(Span::Link(strip_links(after.get(..close).unwrap_or_default()))); rest = after.get(close + 2 + end + 1..).unwrap_or_default(); } else if rest.starts_with("https://github.com/") { let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let url = rest.get(..end).unwrap_or_default(); let tail = url.trim_end_matches(['.', ',', ')', ';']); let number = tail.rsplit('/').next().filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); let kind = tail.rsplit('/').nth(1); match (number, kind) { (Some(n), Some("pull" | "issues")) => { push_plain(&mut out, &mut plain); out.push(Span::Link(format!("#{n}"))); rest = rest.get(tail.len()..).unwrap_or_default(); } _ => { plain.push_str(url); rest = rest.get(end..).unwrap_or_default(); } } } else { plain.push(c); rest = rest.get(c.len_utf8()..).unwrap_or_default(); } } push_plain(&mut out, &mut plain); out } /// Link text inside bold: `**[x](y)**` → `x`. fn strip_links(s: &str) -> String { inline(s) .into_iter() .map(|span| match span { Span::Text(t) | Span::Strong(t) | Span::Code(t) | Span::Link(t) => t, }) .collect() } fn markdown(ui: &mut egui::Ui, src: &str, t: &Tokens) { let body = ui.visuals().text_color(); let job = |spans: &[Span], size: f32, strong: bool, wrap: f32| { let mut job = LayoutJob::default(); job.wrap.max_width = wrap; for span in spans { let (text, font, color) = match span { Span::Text(s) => (s.as_str(), FontId::proportional(size), if strong { t.text } else { body }), Span::Strong(s) => (s.as_str(), FontId::proportional(size), t.text), Span::Code(s) => (s.as_str(), FontId::monospace(size - 1.0), body), Span::Link(s) => (s.as_str(), FontId::proportional(size), t.accent), }; job.append(text, 0.0, egui::TextFormat { font_id: font, color, ..Default::default() }); } job }; // Fixed widths: the notes never widen the window, whatever their lines. let width = NOTES_W; for block in parse_markdown(src) { match block { Block::Heading(spans) => { ui.add_space(4.0); ui.add(egui::Label::new(job(&spans, 14.0, true, width)).wrap()); } Block::Paragraph(spans) => { ui.add(egui::Label::new(job(&spans, 12.5, false, width)).wrap()); ui.add_space(3.0); } Block::Item(depth, spans) => { let indent = 6.0 + 12.0 * depth.min(4) as f32; ui.horizontal_top(|ui| { ui.add_space(indent); ui.label(RichText::new("•").size(12.5)); ui.add(egui::Label::new(job(&spans, 12.5, false, width - indent - 14.0)).wrap()); }); } } } } pub mod install; #[cfg(test)] mod install_tests; #[cfg(test)] mod tests;