Tell people when a newer release is out (#1100)
At start, and at most once a day, a background thread asks GitHub for the latest stable release (one HTTPS GET to releases/latest, nothing else sent; 15 s timeout; silent when offline; GitHub's rate limit respected). When it is newer than this build, an Update button appears next to Report a bug. It opens a window with the version, the release notes and Download (the asset that fits this installation), Skip this version and Remind me later. - Versions compare as semver; a nightly counts as the release it was built after, so it is offered only a newer stable release. - The installation is detected (AppImage, Flatpak, Snap from the store or a file, deb, rpm, tar.gz, Windows MSI/portable/exe, macOS app); Download picks the fixed-name asset. Development builds and the Flatpak (no network permission) never check. - Preferences > General > Updates: Check for updates automatically (on) and Check now. - HTTP via ureq + rustls with the operating system's certificates (desktop app only). - docs: Updates and privacy page. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88432d226a
commit
a6928abf48
21 files changed
+2969
-51
No files matched your search
@@ -131,6 +131,7 @@ pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod toolbar_custom;
|
||||
pub mod tools;
|
||||
pub mod update;
|
||||
pub mod viewport;
|
||||
mod viewport_layout;
|
||||
pub mod workspace;
|
||||
@@ -212,6 +213,8 @@ pub struct Services {
|
||||
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
|
||||
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
|
||||
pub graphics_path: Option<String>,
|
||||
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
|
||||
pub http_get: Option<update::HttpGet>,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
@@ -248,6 +251,8 @@ pub struct SolveApp {
|
||||
pub issue_report: issue_report::ReportState,
|
||||
/// The Crash Reports window.
|
||||
pub crashes: crash_ui::CrashUi,
|
||||
/// The update check and its window (`update`).
|
||||
pub updater: update::Updater,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
pub sketch_choices: sketch_families::Choices,
|
||||
@@ -328,6 +333,7 @@ impl SolveApp {
|
||||
help: help::HelpState::default(),
|
||||
issue_report: issue_report::ReportState::default(),
|
||||
crashes: crash_ui::CrashUi::default(),
|
||||
updater: update::Updater::default(),
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
sketch_choices: sketch_families::Choices::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
@@ -1029,6 +1035,7 @@ impl SolveApp {
|
||||
toolbar::shortcuts(self, ctx);
|
||||
}
|
||||
recovery_ui::tick(self);
|
||||
update::tick(self, ctx);
|
||||
if self.issue_report.draft.is_none() {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
@@ -1092,6 +1099,7 @@ impl SolveApp {
|
||||
issue_report::CadInput::block(self, ui, report_input);
|
||||
file_load::show(self, ui.ctx());
|
||||
documents::prompt(self, ui.ctx());
|
||||
update::show(self, ui.ctx());
|
||||
if self.home.open {
|
||||
home::show(self, ui);
|
||||
if self.home.open {
|
||||
|
||||
@@ -245,6 +245,10 @@ pub struct Prefs {
|
||||
pub offer_stand_up: bool,
|
||||
/// What a middle-button double-click in the view does: `home`, `fit` or `off`.
|
||||
pub middle_double_click: String,
|
||||
/// Check for updates at start and once a day (docs/updates.md).
|
||||
pub check_updates: bool,
|
||||
/// When the last update check ran, and the versions skipped or put off (`update::Memo`).
|
||||
pub update: crate::update::Memo,
|
||||
}
|
||||
|
||||
/// What a middle-button double-click in the view does.
|
||||
@@ -303,6 +307,8 @@ impl Default for Prefs {
|
||||
up_axis: "z".into(),
|
||||
offer_stand_up: true,
|
||||
middle_double_click: "home".into(),
|
||||
check_updates: true,
|
||||
update: crate::update::Memo::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +365,10 @@ impl Snapshot {
|
||||
}
|
||||
fn restore(self, app: &mut SolveApp) {
|
||||
let msaa = app.preferences.msaa;
|
||||
// Update bookkeeping isn't a setting: a check that finished while the dialog was open stays.
|
||||
let update = std::mem::take(&mut app.preferences.update);
|
||||
app.preferences = self.prefs;
|
||||
app.preferences.update = update;
|
||||
app.ui.dark = self.dark;
|
||||
app.ui.show_grid = self.grid;
|
||||
app.ui.ground_shadow = self.shadow;
|
||||
@@ -392,6 +401,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
p.angle_decimals = d.angle_decimals;
|
||||
p.check_updates = d.check_updates;
|
||||
app.ui.dark = true;
|
||||
app.ui.agent_cursor = Default::default();
|
||||
app.autosave_minutes = 5.0;
|
||||
@@ -901,6 +911,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.label("");
|
||||
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, crate::i18n::tr_label("Follow the camera (Look At sketches)")));
|
||||
ui.end_row();
|
||||
crate::update::prefs_rows(app, ui, t);
|
||||
}
|
||||
1 => {
|
||||
ui.label(app.tr_label("Pan, zoom, orbit"));
|
||||
|
||||
@@ -250,7 +250,9 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
let tabs_start = x + 16.0;
|
||||
let (report, report_label) = report_rect(r, captions, tabs_start);
|
||||
let tabs_end = report.left() - 8.0;
|
||||
// A newer release: its Update button, left of Report a bug (`update`).
|
||||
let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label);
|
||||
let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0;
|
||||
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
|
||||
// extend into the persistent report and window controls.
|
||||
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
|
||||
@@ -296,7 +298,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0 - captions, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("v{}", env!("CARGO_PKG_VERSION")),
|
||||
format!("v{}", crate::update::current_version()),
|
||||
FontId::proportional(11.0),
|
||||
Color32::from_rgb(160, 168, 182),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,959 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
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) {
|
||||
if !app.updater.started {
|
||||
app.updater.started = true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 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 {
|
||||
ui.label(RichText::new(crate::i18n::format("update.file", &[("file", name)])).size(11.0).color(t.text_dim));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let download = if kind.system_managed() { crate::i18n::tr("update.release_page") } else { crate::i18n::tr("update.download") };
|
||||
let r = 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;
|
||||
}
|
||||
_ 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn versions_compare_as_semver() {
|
||||
assert!(is_newer("v0.10.0", "0.9.0"));
|
||||
assert!(is_newer("v0.10.0", "0.9.12"), "0.10 is after 0.9.12, not before it as text would sort");
|
||||
assert!(is_newer("v1.0.0", "0.10.0"));
|
||||
assert!(!is_newer("v0.10.0", "0.10.0"));
|
||||
assert!(!is_newer("v0.9.0", "0.10.0"));
|
||||
// A release candidate is older than its release; the release is offered to it.
|
||||
assert!(is_newer("v0.11.0", "0.11.0-rc.2"));
|
||||
// A prerelease is never offered (releases/latest never returns one anyway).
|
||||
assert!(!is_newer("v0.12.0-rc.1", "0.11.0"));
|
||||
assert!(!is_newer("garbage", "0.9.0"));
|
||||
assert!(!is_newer("v0.10.0", "not a version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nightlies_count_as_the_release_they_follow() {
|
||||
// Built from main after 0.10.0: not nagged about 0.10.0 itself.
|
||||
assert!(!is_newer("v0.10.0", "0.10.0-nightly.20261015.37.1"));
|
||||
assert!(!is_newer("v0.9.0", "0.10.0-nightly.20261015.37.1"));
|
||||
// A newer stable release is offered.
|
||||
assert!(is_newer("v0.11.0", "0.10.0-nightly.20261015.37.1"));
|
||||
assert!(is_newer("v0.10.0", "0.9.0-nightly.20261014.36.1"));
|
||||
}
|
||||
|
||||
fn probe(os: &str, release: bool, env: &[(&str, &str)], exe: &str, files: &[&str]) -> InstallKind {
|
||||
let env: BTreeMap<String, String> = env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
let files: Vec<PathBuf> = files.iter().map(PathBuf::from).collect();
|
||||
let get = |k: &str| env.get(k).cloned();
|
||||
let exists = |p: &Path| files.iter().any(|f| f == p);
|
||||
detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_kinds_are_told_apart() {
|
||||
let home = "/home/me/Apps/solidcraft";
|
||||
assert_eq!(probe("linux", false, &[("APPIMAGE", "/x.AppImage")], home, &[]), InstallKind::Dev, "dev builds never check");
|
||||
assert_eq!(probe("linux", true, &[("FLATPAK_ID", APP_ID)], "/app/bin/solidcraft", &[]), InstallKind::Flatpak(APP_ID.into()));
|
||||
let snap = |rev: &str| {
|
||||
probe(
|
||||
"linux",
|
||||
true,
|
||||
&[("SNAP", "/snap/solidcraft/12"), ("SNAP_NAME", "solidcraft"), ("SNAP_REVISION", rev)],
|
||||
"/snap/solidcraft/12/usr/bin/solidcraft",
|
||||
&[],
|
||||
)
|
||||
};
|
||||
assert_eq!(snap("12"), InstallKind::Snap { store: true });
|
||||
assert_eq!(snap("x3"), InstallKind::Snap { store: false }, "installed from a file with --dangerous");
|
||||
assert_eq!(
|
||||
probe("linux", true, &[("APPIMAGE", "/home/me/SolidCraft.AppImage")], "/tmp/.mount_SolidXYZ/usr/bin/solidcraft", &[]),
|
||||
InstallKind::AppImage("/home/me/SolidCraft.AppImage".into())
|
||||
);
|
||||
// An empty variable doesn't count.
|
||||
assert_eq!(probe("linux", true, &[("APPIMAGE", "")], "/opt/sc/bin/solidcraft", &[]), InstallKind::LinuxTarball);
|
||||
assert_eq!(probe("linux", true, &[], "/usr/bin/solidcraft", &["/var/lib/dpkg/info/solidcraft.list", "/var/lib/rpm"]), InstallKind::Deb);
|
||||
assert_eq!(probe("linux", true, &[], "/usr/bin/solidcraft", &["/var/lib/rpm"]), InstallKind::Rpm);
|
||||
assert_eq!(probe("linux", true, &[], "/usr/bin/solidcraft", &["/usr/lib/sysimage/rpm"]), InstallKind::Rpm);
|
||||
assert_eq!(probe("linux", true, &[], "/usr/bin/solidcraft", &[]), InstallKind::Other);
|
||||
assert_eq!(probe("linux", true, &[], home, &[]), InstallKind::LinuxTarball);
|
||||
|
||||
let pf = [("ProgramFiles", "C:\\Program Files"), ("ProgramFiles(x86)", "C:\\Program Files (x86)")];
|
||||
assert_eq!(probe("windows", true, &pf, "C:\\Program Files\\SolidCraft\\solidcraft.exe", &[]), InstallKind::WindowsMsi);
|
||||
assert_eq!(probe("windows", true, &pf, "c:\\program files\\SolidCraft\\solidcraft.exe", &[]), InstallKind::WindowsMsi, "case-insensitive");
|
||||
// "C:\Program Files Extra" is not under "C:\Program Files".
|
||||
assert_eq!(probe("windows", true, &pf, "C:\\Program Files Extra\\solidcraft.exe", &[]), InstallKind::WindowsExe);
|
||||
let portable = "C:\\Users\\me\\solidcraft-0.10.0-windows-x64-portable\\solidcraft.exe";
|
||||
let cli = "C:\\Users\\me\\solidcraft-0.10.0-windows-x64-portable\\solidcraft-cli.exe";
|
||||
// Path::parent splits on `\` only on Windows; this test describes the layout either way.
|
||||
if cfg!(windows) {
|
||||
assert_eq!(probe("windows", true, &pf, portable, &[cli]), InstallKind::WindowsPortable);
|
||||
}
|
||||
assert_eq!(probe("windows", true, &pf, "C:\\Users\\me\\Downloads\\SolidCraft-0.10.0-windows-x64.exe", &[]), InstallKind::WindowsExe);
|
||||
|
||||
assert_eq!(
|
||||
probe("macos", true, &[], "/Applications/SolidCraft.app/Contents/MacOS/solidcraft", &[]),
|
||||
InstallKind::MacApp("/Applications/SolidCraft.app".into())
|
||||
);
|
||||
assert_eq!(probe("macos", true, &[], "/usr/local/bin/solidcraft", &[]), InstallKind::Other);
|
||||
assert_eq!(probe("macos", false, &[], "/Applications/SolidCraft.app/Contents/MacOS/solidcraft", &[]), InstallKind::Dev);
|
||||
assert_eq!(probe("freebsd", true, &[], "/usr/local/bin/solidcraft", &[]), InstallKind::Other);
|
||||
assert!(!InstallKind::Dev.checks() && !InstallKind::Web.checks() && InstallKind::Snap { store: true }.checks());
|
||||
assert!(!InstallKind::Flatpak(APP_ID.into()).checks(), "the Flatpak has no network permission; Flatpak updates it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_windows_layout_is_recognised() {
|
||||
let exe = PathBuf::from("/portable/solidcraft.exe");
|
||||
let get = |k: &str| (k == "ProgramFiles").then(|| "C:\\Program Files".to_string());
|
||||
let exists = |p: &Path| p == Path::new("/portable/solidcraft-cli.exe");
|
||||
assert_eq!(detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists }), InstallKind::WindowsPortable);
|
||||
}
|
||||
|
||||
/// The assets of v0.10.0, as published.
|
||||
fn release() -> Release {
|
||||
let names = [
|
||||
"SHA256SUMS.txt",
|
||||
"solidcraft-0.10.0-linux-x86_64.AppImage",
|
||||
"solidcraft-0.10.0-linux-x86_64.AppImage.zsync",
|
||||
"solidcraft-0.10.0-linux-x86_64.tar.gz",
|
||||
"solidcraft-0.10.0-linux-aarch64.tar.gz",
|
||||
"SolidCraft-0.10.0-windows-x64.exe",
|
||||
"solidcraft-linux-aarch64.AppImage",
|
||||
"solidcraft-linux-aarch64.deb",
|
||||
"solidcraft-linux-x86_64.AppImage",
|
||||
"solidcraft-linux-x86_64.deb",
|
||||
"solidcraft-linux-x86_64.flatpak",
|
||||
"solidcraft-linux-x86_64.rpm",
|
||||
"solidcraft-linux-x86_64.snap",
|
||||
"solidcraft-macos-universal.dmg",
|
||||
"solidcraft-windows-arm64-portable.zip",
|
||||
"solidcraft-windows-arm64.msi",
|
||||
"solidcraft-windows-x64-portable.zip",
|
||||
"solidcraft-windows-x64.msi",
|
||||
"solidcraft-windows-x86-portable.zip",
|
||||
"solidcraft-windows-x86.msi",
|
||||
];
|
||||
let assets: Vec<Value> = names
|
||||
.iter()
|
||||
.map(|n| json!({"name": n, "size": 10, "browser_download_url": format!("https://github.com/bherbruck/solidcraft/releases/download/v0.10.0/{n}")}))
|
||||
.collect();
|
||||
let body = json!({
|
||||
"tag_name": "v0.10.0", "name": "SolidCraft v0.10.0", "body": "**What's new**\n\n- Surface tools.",
|
||||
"html_url": "https://github.com/bherbruck/solidcraft/releases/tag/v0.10.0", "draft": false, "prerelease": false, "assets": assets,
|
||||
});
|
||||
Release::parse(body.to_string().as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_installation_gets_its_own_file() {
|
||||
let r = release();
|
||||
let name = |k: InstallKind, arch: &str| r.asset_for(&k, arch).map(|a| a.name.clone());
|
||||
assert_eq!(name(InstallKind::AppImage("/a".into()), "x86_64").as_deref(), Some("solidcraft-linux-x86_64.AppImage"));
|
||||
assert_eq!(name(InstallKind::AppImage("/a".into()), "aarch64").as_deref(), Some("solidcraft-linux-aarch64.AppImage"));
|
||||
assert_eq!(name(InstallKind::Deb, "x86_64").as_deref(), Some("solidcraft-linux-x86_64.deb"));
|
||||
assert_eq!(name(InstallKind::Rpm, "x86_64").as_deref(), Some("solidcraft-linux-x86_64.rpm"));
|
||||
assert_eq!(name(InstallKind::LinuxTarball, "aarch64").as_deref(), Some("solidcraft-0.10.0-linux-aarch64.tar.gz"));
|
||||
assert_eq!(name(InstallKind::WindowsMsi, "x86_64").as_deref(), Some("solidcraft-windows-x64.msi"));
|
||||
assert_eq!(name(InstallKind::WindowsMsi, "aarch64").as_deref(), Some("solidcraft-windows-arm64.msi"));
|
||||
assert_eq!(name(InstallKind::WindowsMsi, "x86").as_deref(), Some("solidcraft-windows-x86.msi"));
|
||||
assert_eq!(name(InstallKind::WindowsPortable, "x86_64").as_deref(), Some("solidcraft-windows-x64-portable.zip"));
|
||||
assert_eq!(name(InstallKind::WindowsExe, "x86_64").as_deref(), Some("SolidCraft-0.10.0-windows-x64.exe"));
|
||||
assert_eq!(name(InstallKind::WindowsExe, "aarch64").as_deref(), Some("solidcraft-windows-arm64-portable.zip"));
|
||||
assert_eq!(name(InstallKind::MacApp("/A.app".into()), "aarch64").as_deref(), Some("solidcraft-macos-universal.dmg"));
|
||||
assert_eq!(name(InstallKind::Other, "x86_64"), None);
|
||||
assert_eq!(name(InstallKind::Deb, "riscv64"), None);
|
||||
// Download: the file, or the page when there is none or a package manager updates it.
|
||||
assert_eq!(
|
||||
r.download_url(&InstallKind::WindowsMsi, "x86_64"),
|
||||
"https://github.com/bherbruck/solidcraft/releases/download/v0.10.0/solidcraft-windows-x64.msi"
|
||||
);
|
||||
assert_eq!(r.download_url(&InstallKind::Other, "x86_64"), "https://github.com/bherbruck/solidcraft/releases/tag/v0.10.0");
|
||||
assert_eq!(r.download_url(&InstallKind::Flatpak(APP_ID.into()), "x86_64"), r.page);
|
||||
assert_eq!(r.download_url(&InstallKind::Snap { store: true }, "x86_64"), r.page);
|
||||
assert_eq!(
|
||||
r.download_url(&InstallKind::Snap { store: false }, "x86_64"),
|
||||
"https://github.com/bherbruck/solidcraft/releases/download/v0.10.0/solidcraft-linux-x86_64.snap"
|
||||
);
|
||||
assert_eq!(
|
||||
InstallKind::Snap { store: false }.update_command(Some("solidcraft-linux-x86_64.snap")).as_deref(),
|
||||
Some("sudo snap install --dangerous ./solidcraft-linux-x86_64.snap")
|
||||
);
|
||||
assert_eq!(InstallKind::Flatpak(APP_ID.into()).update_command(None).as_deref(), Some("flatpak update ai.storyteller.solidcraft"));
|
||||
assert_eq!(
|
||||
InstallKind::Deb.update_command(Some("solidcraft-linux-x86_64.deb")).as_deref(),
|
||||
Some("sudo apt install ./solidcraft-linux-x86_64.deb")
|
||||
);
|
||||
assert_eq!(InstallKind::WindowsMsi.update_command(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_answers_are_checked() {
|
||||
let r = release();
|
||||
assert_eq!((r.tag.as_str(), r.version(), r.assets.len()), ("v0.10.0", "0.10.0", 20));
|
||||
let bad = |v: Value| Release::parse(v.to_string().as_bytes()).is_err();
|
||||
assert!(bad(json!({"tag_name": "v0.11.0-rc.1", "prerelease": true})));
|
||||
assert!(bad(json!({"tag_name": "v0.11.0", "draft": true})));
|
||||
assert!(bad(json!({"tag_name": "nightly"})));
|
||||
assert!(Release::parse(b"<html>").is_err());
|
||||
// Links elsewhere are dropped: the page falls back to the releases page, foreign assets go.
|
||||
let r = Release::parse(
|
||||
json!({"tag_name": "v1.0.0", "html_url": "https://evil.example/x", "assets": [
|
||||
{"name": "solidcraft-windows-x64.msi", "browser_download_url": "https://evil.example/a.msi"},
|
||||
{"name": "solidcraft-windows-x86.msi", "browser_download_url": "https://github.com/bherbruck/solidcraft/releases/download/v1.0.0/solidcraft-windows-x86.msi"},
|
||||
]})
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.page, RELEASES_PAGE);
|
||||
assert_eq!(r.assets.len(), 1);
|
||||
assert_eq!(r.name, "SolidCraft v1.0.0");
|
||||
// Huge notes are cut (on a character boundary).
|
||||
let r = Release::parse(json!({"tag_name": "v1.0.0", "body": "é".repeat(MAX_NOTES)}).to_string().as_bytes()).unwrap();
|
||||
assert!(r.notes.len() <= MAX_NOTES + 8 && r.notes.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_and_remind_later() {
|
||||
let r = release();
|
||||
let mut m = Memo::default();
|
||||
let now = 1_800_000_000.0;
|
||||
assert!(m.offers(&r, "0.9.0", now));
|
||||
assert!(!m.offers(&r, "0.10.0", now));
|
||||
m.remind_later(now);
|
||||
assert!(!m.offers(&r, "0.9.0", now + 3600.0));
|
||||
assert!(m.offers(&r, "0.9.0", now + REMIND_DELAY_S + 1.0));
|
||||
m.skip(&r);
|
||||
assert!(!m.offers(&r, "0.9.0", now + REMIND_DELAY_S + 1.0), "a skipped version stays hidden");
|
||||
let mut next = r.clone();
|
||||
next.tag = "v0.11.0".into();
|
||||
assert!(m.offers(&next, "0.9.0", now + REMIND_DELAY_S + 1.0), "a later version shows again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_run_daily_and_back_off() {
|
||||
let mut m = Memo::default();
|
||||
let now = 1_800_000_000.0;
|
||||
assert!(m.due(now), "the first start checks");
|
||||
m.checked(now, &Ok(release()));
|
||||
assert!(!m.due(now + 3600.0));
|
||||
assert!(m.due(now + CHECK_INTERVAL_S));
|
||||
assert!(m.due(now - 2.0 * CHECK_INTERVAL_S), "a clock set back doesn't block checks for days");
|
||||
m.checked(now, &Err(FetchError::Failed("offline".into())));
|
||||
assert!(!m.due(now + 60.0));
|
||||
assert!(m.due(now + RETRY_AFTER_FAILURE_S));
|
||||
m.checked(now, &Err(FetchError::RateLimited { retry_after_s: 1800 }));
|
||||
assert!(!m.due(now + 1700.0));
|
||||
assert!(m.due(now + 1800.0));
|
||||
assert_eq!(m.rate_limited_until, now + 1800.0);
|
||||
// Absurd waits are capped at a day.
|
||||
m.checked(now, &Err(FetchError::RateLimited { retry_after_s: u64::MAX }));
|
||||
assert!(m.due(now + CHECK_INTERVAL_S));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_memo_survives_with_the_preferences() {
|
||||
let mut p = crate::prefs::Prefs::default();
|
||||
assert!(p.check_updates);
|
||||
p.update.skipped = "0.10.0".into();
|
||||
p.update.remind_after = 5.0;
|
||||
let back: crate::prefs::Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert_eq!(back, p);
|
||||
// Older preferences without these fields load with the defaults.
|
||||
let old: crate::prefs::Prefs = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
|
||||
assert!(old.check_updates && old.update == Memo::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_notes_read_as_markdown() {
|
||||
let md = "## What's new\n\n**Why 0.10:** a minor\nbump.\n\n- **Surface tools.** Revolve and `solidcraft-cli mcp`\n continued ([#925](https://x/925)).\n - nested\n* Fix by @a in https://github.com/bherbruck/solidcraft/pull/1064\n\nSee https://example.com/x.";
|
||||
let b = parse_markdown(md);
|
||||
assert_eq!(b.first(), Some(&Block::Heading(vec![Span::Text("What's new".into())])));
|
||||
assert_eq!(b.get(1), Some(&Block::Paragraph(vec![Span::Strong("Why 0.10:".into()), Span::Text(" a minor bump.".into())])));
|
||||
assert_eq!(
|
||||
b.get(2),
|
||||
Some(&Block::Item(
|
||||
0,
|
||||
vec![
|
||||
Span::Strong("Surface tools.".into()),
|
||||
Span::Text(" Revolve and ".into()),
|
||||
Span::Code("solidcraft-cli mcp".into()),
|
||||
Span::Text(" continued (".into()),
|
||||
Span::Link("#925".into()),
|
||||
Span::Text(").".into()),
|
||||
]
|
||||
))
|
||||
);
|
||||
assert_eq!(b.get(3), Some(&Block::Item(1, vec![Span::Text("nested".into())])));
|
||||
assert_eq!(b.get(4), Some(&Block::Item(0, vec![Span::Text("Fix by @a in ".into()), Span::Link("#1064".into())])));
|
||||
assert_eq!(b.get(5), Some(&Block::Paragraph(vec![Span::Text("See https://example.com/x.".into())])));
|
||||
// Unclosed markers and odd input show as written, without panicking.
|
||||
for s in ["**open", "`tick", "[a](b", "[a]", "#", "- ", "é**", "https://github.com/", "**[x](y)**"] {
|
||||
let _ = parse_markdown(s);
|
||||
}
|
||||
assert_eq!(inline("**[x](y)**"), vec![Span::Strong("x".into())]);
|
||||
}
|
||||
Reference in new issue
Block a user