Where SolidCraft can replace itself safely, the Update window gets Update now: - AppImage: the new AppImage is downloaded next to the old one, verified and renamed over it; Restart now starts it as SolidCraft exits. - Windows MSI: the verified .msi starts (msiexec /i … /passive) once SolidCraft has closed, after the usual questions about unsaved designs. - Windows portable zip / standalone exe: the running solidcraft.exe is renamed to .old, the new one takes its name (solidcraft-cli.exe too), Restart now; .old goes at the next start. - macOS: the verified .dmg is opened (the app is ad-hoc signed and not notarized, so it isn't replaced behind the user's back). - Flatpak, Snap, deb, rpm, tar.gz: never replaced; the window shows the command. Every package's SHA-256 must match the release's SHA256SUMS.txt (HTTPS, same release); a mismatch deletes the download and changes nothing. With PUBLIC_KEY set in update/install.rs, SHA256SUMS.txt.minisig must also verify (minisign-verify, MIT) with a trusted comment naming the release. release.yml signs the list when the UPDATE_SIGNING_KEY secret exists and fails when the app expects signatures but the secret is missing; docs/releasing.md has the steps. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1130 lines
47 KiB
Rust
1130 lines
47 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::{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<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(¤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-<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, 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<egui::Rect> {
|
|
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<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;
|