Files
OneCAD/crates/ui-egui/src/update.rs
T
Jan Szumiec e8c340c59d Web: installable as an app, with Install in the File menu (#1233)
The web build ships a manifest so the browser can install it as an app, and offers Install in the File menu while the browser allows it.
2026-10-15 11:34:57 -04:00

1106 lines
46 KiB
Rust

//! 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::{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<dyn Fn(&str) -> Result<Vec<u8>, 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<semver::Version> {
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(&current)
}
/// 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-<version>-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<String>,
/// The running executable.
pub exe: Option<PathBuf>,
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<PathBuf> {
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<String> {
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<String> {
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<Asset>,
}
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<Release, FetchError> {
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::<String>())));
}
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<Release, FetchError>) {
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<InstallKind>,
pub status: Status,
/// The newest release found (whether or not it is offered).
pub release: Option<Release>,
/// The Update window is open.
pub open: bool,
/// The check in flight, and whether Check now started it.
rx: Option<(Receiver<Result<Release, FetchError>>, 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<PathBuf>,
/// The one-click update in progress (`install`).
job: Option<(Receiver<Result<install::Outcome, String>>, Arc<install::Progress>)>,
/// The last one-click update's result.
pub installed: Option<Result<install::Outcome, String>>,
/// A program for the host to start as SolidCraft exits (restart, installer).
launch: Option<(PathBuf, Vec<String>)>,
}
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<Release, FetchError> {
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<install::Method> {
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<String>)> {
app.updater.launch.take()
}
// ---- user interface ---------------------------------------------------------------------------
/// The application bar's Update button, at the right of `span`; returns its rect (None: not shown).
pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option<egui::Rect> {
offered(app)?;
let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?;
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<String> {
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<Span>),
Paragraph(Vec<Span>),
/// A list item, at a nesting depth.
Item(usize, Vec<Span>),
}
/// 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<Block> {
let mut blocks = Vec::new();
let mut para = String::new();
let mut item: Option<(usize, String)> = None;
let flush = |blocks: &mut Vec<Block>, 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<Span> {
let mut out: Vec<Span> = Vec::new();
let mut plain = String::new();
let mut rest = text;
let push_plain = |out: &mut Vec<Span>, 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;