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 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 10:36:56 -04:00
1 parent 88432d226a
commit a6928abf48
21 files changed
+2969 -51

No files matched your search

Generated
+225 -3
View File
@@ -422,6 +422,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -710,6 +716,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -723,7 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@@ -736,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@@ -1578,6 +1594,22 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -2449,6 +2481,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2949,6 +2987,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "robust"
version = "1.2.0"
@@ -3016,6 +3068,80 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3031,6 +3157,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3043,6 +3178,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -3295,9 +3453,11 @@ dependencies = [
"objc2-foundation 0.3.2",
"quick-xml",
"rfd",
"rustls",
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
"ureq",
"winit",
]
@@ -3433,6 +3593,7 @@ dependencies = [
"image",
"log",
"pollster 0.4.0",
"semver",
"serde",
"serde_json",
"solidcraft-engine",
@@ -3490,6 +3651,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -3912,6 +4079,40 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -3924,6 +4125,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4246,6 +4453,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -4734,7 +4950,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -5042,6 +5258,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
+1
View File
@@ -31,6 +31,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
truck-modeling = "0.6"
+1229 -36
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -28,6 +28,9 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
+40
View File
@@ -113,6 +113,7 @@ fn services() -> Services {
dialog.save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
@@ -133,6 +134,45 @@ fn services() -> Services {
}
}
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
/// the version or anything about this machine, and the API's media type).
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.https_only(true)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(15)))
.timeout_connect(Some(Duration::from_secs(5)))
.user_agent("SolidCraft-update-check")
.max_redirects(3)
.build()
.into();
let mut response = agent
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.call()
.map_err(|e| FetchError::Failed(e.to_string()))?;
let status = response.status().as_u16();
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
return Err(FetchError::RateLimited { retry_after_s });
}
if status != 200 {
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
}
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
}
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
fn settings_dir() -> Option<std::path::PathBuf> {
eframe::storage_dir("SolidCraft")
+6
View File
@@ -266,6 +266,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(17.0, y), (21.0, y)], ink);
}
}
// Update: an arrow down onto a tray (a newer release can be downloaded).
"update" => {
pen.line(&[(12.0, 3.5), (12.0, 14.5)], ink);
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
}
// Help: a question mark in a ring.
"help" => {
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
+25 -1
View File
@@ -701,5 +701,29 @@
"sketch_text.align_right": "Rechts",
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
"sketch_text.path_clear": "Text von der Kurve lösen",
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar",
"update.title": "Update verfügbar",
"update.available": "SolidCraft {version} ist verfügbar",
"update.you_have": "Installiert ist {version}.",
"update.no_notes": "Zu diesem Release gibt es keine Hinweise.",
"update.system_hint": "Diese Installation ist ein {kind}. Aktualisieren mit:",
"update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:",
"update.copy": "Kopieren",
"update.file": "Herunterladen holt {file}.",
"update.download": "Herunterladen",
"update.release_page": "Release-Seite öffnen",
"update.skip": "Diese Version überspringen",
"update.later": "Später erinnern",
"update.prefs_label": "Updates",
"update.check_automatically": "Automatisch nach Updates suchen",
"update.check_now": "Jetzt suchen",
"update.checking": "Suche läuft…",
"update.failed": "Suche fehlgeschlagen: {error}",
"update.up_to_date": "SolidCraft ist aktuell.",
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
"update.show": "Update auf {version} anzeigen",
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst."
}
+26 -2
View File
@@ -959,6 +959,30 @@
"Line Spacing": "Line Spacing",
"Path": "Path",
"sketch_text.on_path": "The text follows its path: the curve places it",
"cmd.material_properties_validate":"Validate Physical Material Data",
"cmd.material_properties_directional":"Directional Elastic Modulus"
"cmd.material_properties_validate": "Validate Physical Material Data",
"cmd.material_properties_directional": "Directional Elastic Modulus",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} is available",
"update.title": "Update available",
"update.available": "SolidCraft {version} is available",
"update.you_have": "You have {version}.",
"update.no_notes": "This release has no notes.",
"update.system_hint": "This copy is a {kind}. Update it with:",
"update.package_hint": "This copy came from a {kind}. Download the new package, then install it with:",
"update.copy": "Copy",
"update.file": "Download gets {file}.",
"update.download": "Download",
"update.release_page": "Open release page",
"update.skip": "Skip this version",
"update.later": "Remind me later",
"update.prefs_label": "Updates",
"update.check_automatically": "Check for updates automatically",
"update.check_now": "Check now",
"update.checking": "Checking…",
"update.failed": "Couldn't check: {error}",
"update.up_to_date": "SolidCraft is up to date.",
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
"update.show": "Show the update to {version}",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check."
}
+26 -2
View File
@@ -959,6 +959,30 @@
"Line Spacing": "行間",
"Path": "パス",
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
"cmd.material_properties_validate":"物理材料データを検証",
"cmd.material_properties_directional":"方向別弾性率"
"cmd.material_properties_validate": "物理材料データを検証",
"cmd.material_properties_directional": "方向別弾性率",
"update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます",
"update.title": "アップデートがあります",
"update.available": "SolidCraft {version} が利用できます",
"update.you_have": "現在のバージョンは {version} です。",
"update.no_notes": "このリリースにはリリースノートがありません。",
"update.system_hint": "このコピーは {kind} です。次のコマンドで更新します:",
"update.package_hint": "このコピーは {kind} からインストールされました。新しいパッケージをダウンロードし、次のコマンドでインストールします:",
"update.copy": "コピー",
"update.file": "ダウンロードするファイル: {file}",
"update.download": "ダウンロード",
"update.release_page": "リリースページを開く",
"update.skip": "このバージョンをスキップ",
"update.later": "後で通知",
"update.prefs_label": "アップデート",
"update.check_automatically": "アップデートを自動的に確認",
"update.check_now": "今すぐ確認",
"update.checking": "確認中…",
"update.failed": "確認できませんでした: {error}",
"update.up_to_date": "SolidCraft は最新です。",
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
"update.show": "{version} へのアップデートを表示",
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。"
}
+25 -1
View File
@@ -701,5 +701,29 @@
"sketch_text.align_right": "오른쪽",
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다",
"update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능",
"update.title": "업데이트 사용 가능",
"update.available": "SolidCraft {version} 사용 가능",
"update.you_have": "현재 버전은 {version}입니다.",
"update.no_notes": "이 릴리스에는 릴리스 노트가 없습니다.",
"update.system_hint": "이 사본은 {kind}입니다. 다음 명령으로 업데이트하세요:",
"update.package_hint": "이 사본은 {kind}(으)로 설치되었습니다. 새 패키지를 내려받은 뒤 다음 명령으로 설치하세요:",
"update.copy": "복사",
"update.file": "내려받을 파일: {file}",
"update.download": "다운로드",
"update.release_page": "릴리스 페이지 열기",
"update.skip": "이 버전 건너뛰기",
"update.later": "나중에 알림",
"update.prefs_label": "업데이트",
"update.check_automatically": "업데이트 자동 확인",
"update.check_now": "지금 확인",
"update.checking": "확인 중…",
"update.failed": "확인할 수 없습니다: {error}",
"update.up_to_date": "SolidCraft가 최신 버전입니다.",
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
"update.show": "{version} 업데이트 보기",
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다."
}
+26 -2
View File
@@ -959,6 +959,30 @@
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
"ui.analysis": "分析",
"ui.edit_section_analysis": "编辑截面分析",
"cmd.material_properties_validate":"验证物理材料数据",
"cmd.material_properties_directional":"方向弹性模量"
"cmd.material_properties_validate": "验证物理材料数据",
"cmd.material_properties_directional": "方向弹性模量",
"update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布",
"update.title": "有可用更新",
"update.available": "SolidCraft {version} 已发布",
"update.you_have": "当前版本为 {version}。",
"update.no_notes": "此版本没有发布说明。",
"update.system_hint": "此副本为 {kind}。用以下命令更新:",
"update.package_hint": "此副本来自 {kind}。下载新软件包后用以下命令安装:",
"update.copy": "复制",
"update.file": "将下载 {file}。",
"update.download": "下载",
"update.release_page": "打开发布页面",
"update.skip": "跳过此版本",
"update.later": "稍后提醒",
"update.prefs_label": "更新",
"update.check_automatically": "自动检查更新",
"update.check_now": "立即检查",
"update.checking": "正在检查…",
"update.failed": "无法检查: {error}",
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
"update.show": "显示 {version} 更新",
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。"
}
+1
View File
@@ -19,6 +19,7 @@ log = { workspace = true }
web-time = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
ttf-parser = { workspace = true }
semver = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
# The offscreen viewport render test opens a device without a window.
+8
View File
@@ -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 {
+11
View File
@@ -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"));
+4 -2
View File
@@ -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),
);
+959
View File
@@ -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(&current)
}
/// This build's version: the release workflow's `SOLIDCRAFT_VERSION` (with any nightly suffix)
/// when it built this binary, else the crate version.
pub fn current_version() -> &'static str {
option_env!("SOLIDCRAFT_VERSION").filter(|v| !v.is_empty()).unwrap_or(env!("CARGO_PKG_VERSION"))
}
/// The release workflow built this binary (`SOLIDCRAFT_VERSION` is set there). Anything else is a
/// development build, which never checks for updates.
pub fn release_build() -> bool {
option_env!("SOLIDCRAFT_VERSION").is_some_and(|v| !v.is_empty()) && !cfg!(debug_assertions)
}
// ---- installations ----------------------------------------------------------------------------
/// How this copy of SolidCraft was installed: decides which file Download fetches and whether
/// the system's package manager is the way to update.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InstallKind {
/// Built from source (or by something other than the release workflow): never checks.
Dev,
/// The browser app (updates itself).
Web,
/// An AppImage, at this path.
AppImage(PathBuf),
/// A Flatpak, with this app ID. It has no network access and Flatpak updates it: never checks.
Flatpak(String),
/// A Snap: from the Snap Store (`store`), or a downloaded `.snap` installed with `--dangerous`.
Snap {
store: bool,
},
Deb,
Rpm,
/// The plain `.tar.gz`, unpacked somewhere.
LinuxTarball,
/// Installed by the `.msi` (under Program Files).
WindowsMsi,
/// The portable `.zip`, unpacked somewhere.
WindowsPortable,
/// The lone `SolidCraft-<version>-windows-x64.exe`.
WindowsExe,
/// `SolidCraft.app`, at this path (the bundle).
MacApp(PathBuf),
/// A release build in a place none of the above explains (FreeBSD, a copied binary).
Other,
}
/// What [`detect`] looks at, so tests can describe any machine.
pub struct Probe<'a> {
/// `std::env::consts::OS`.
pub os: &'a str,
pub release_build: bool,
pub env: &'a dyn Fn(&str) -> Option<String>,
/// The running executable.
pub exe: Option<PathBuf>,
pub exists: &'a dyn Fn(&Path) -> bool,
}
pub fn detect(p: &Probe) -> InstallKind {
if p.os == "unknown" || cfg!(target_arch = "wasm32") {
return InstallKind::Web;
}
if !p.release_build {
return InstallKind::Dev;
}
let env = |k: &str| (p.env)(k).filter(|v| !v.trim().is_empty());
let Some(exe) = p.exe.clone() else { return InstallKind::Other };
match p.os {
"linux" => {
if let Some(id) = env("FLATPAK_ID") {
return InstallKind::Flatpak(id);
}
if env("SNAP").is_some() && env("SNAP_NAME").is_none_or(|n| n.starts_with("solidcraft")) {
// Locally installed (unasserted) snaps have revisions x1, x2, …
return InstallKind::Snap { store: !env("SNAP_REVISION").is_some_and(|r| r.starts_with('x')) };
}
if let Some(image) = env("APPIMAGE") {
return InstallKind::AppImage(PathBuf::from(image));
}
if exe.starts_with("/usr") {
// nfpm installs both packages under /usr; dpkg keeps a file list per package.
if (p.exists)(Path::new("/var/lib/dpkg/info/solidcraft.list")) {
return InstallKind::Deb;
}
if (p.exists)(Path::new("/var/lib/rpm")) || (p.exists)(Path::new("/usr/lib/sysimage/rpm")) {
return InstallKind::Rpm;
}
return InstallKind::Other;
}
InstallKind::LinuxTarball
}
"windows" => {
let under = |k: &str| env(k).is_some_and(|dir| starts_with_ci(&exe, Path::new(&dir)));
if under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") {
return InstallKind::WindowsMsi;
}
if exe.parent().is_some_and(|d| (p.exists)(&d.join("solidcraft-cli.exe"))) {
return InstallKind::WindowsPortable;
}
InstallKind::WindowsExe
}
"macos" => app_bundle(&exe).map_or(InstallKind::Other, InstallKind::MacApp),
_ => InstallKind::Other,
}
}
/// `path` is inside `dir` (Windows paths: case-insensitive).
fn starts_with_ci(path: &Path, dir: &Path) -> bool {
let (p, d) = (path.to_string_lossy().to_lowercase().replace('/', "\\"), dir.to_string_lossy().to_lowercase().replace('/', "\\"));
let d = d.trim_end_matches('\\');
!d.is_empty() && p.strip_prefix(d).is_some_and(|rest| rest.starts_with('\\'))
}
/// The `.app` bundle an executable at `…/X.app/Contents/MacOS/x` belongs to.
fn app_bundle(exe: &Path) -> Option<PathBuf> {
let macos = exe.parent()?;
let contents = macos.parent()?;
let bundle = contents.parent()?;
(macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" && bundle.extension()? == "app").then(|| bundle.to_path_buf())
}
/// This machine's installation, looked up once.
pub fn this_install() -> InstallKind {
#[cfg(target_arch = "wasm32")]
{
InstallKind::Web
}
#[cfg(not(target_arch = "wasm32"))]
{
let env = |k: &str| std::env::var(k).ok();
let exists = |p: &Path| p.exists();
detect(&Probe { os: std::env::consts::OS, release_build: release_build(), env: &env, exe: std::env::current_exe().ok(), exists: &exists })
}
}
impl InstallKind {
/// The package manager updates it: Download isn't the way (the window shows the command).
pub fn system_managed(&self) -> bool {
matches!(self, InstallKind::Flatpak(_) | InstallKind::Snap { store: true })
}
/// A short name for Preferences and docs.
pub fn label(&self) -> &'static str {
match self {
InstallKind::Dev => "development build",
InstallKind::Web => "browser app",
InstallKind::AppImage(_) => "AppImage",
InstallKind::Flatpak(_) => "Flatpak",
InstallKind::Snap { store: true } => "Snap",
InstallKind::Snap { store: false } => "Snap (installed from a file)",
InstallKind::Deb => ".deb package",
InstallKind::Rpm => ".rpm package",
InstallKind::LinuxTarball => ".tar.gz",
InstallKind::WindowsMsi => "Windows installer (.msi)",
InstallKind::WindowsPortable => "portable .zip",
InstallKind::WindowsExe => "standalone .exe",
InstallKind::MacApp(_) => "macOS app",
InstallKind::Other => "other",
}
}
/// Release builds check; development builds, the browser app and the Flatpak (no network
/// permission; Flatpak updates it) don't.
pub fn checks(&self) -> bool {
!matches!(self, InstallKind::Dev | InstallKind::Web | InstallKind::Flatpak(_))
}
/// The command that updates a package-managed installation, if there is one.
pub fn update_command(&self, asset: Option<&str>) -> Option<String> {
match self {
InstallKind::Flatpak(id) => Some(format!("flatpak update {id}")),
InstallKind::Snap { store: true } => Some("sudo snap refresh solidcraft".into()),
InstallKind::Snap { store: false } => {
Some(format!("sudo snap install --dangerous ./{}", asset.unwrap_or("solidcraft-linux-x86_64.snap")))
}
InstallKind::Deb => Some(format!("sudo apt install ./{}", asset.unwrap_or("solidcraft-linux-x86_64.deb"))),
InstallKind::Rpm => Some(format!("sudo dnf install ./{}", asset.unwrap_or("solidcraft-linux-x86_64.rpm"))),
_ => None,
}
}
}
/// The release asset that installs or replaces this installation, by its fixed name
/// (docs/releasing.md: `solidcraft-windows-x64.msi`, `solidcraft-linux-x86_64.AppImage`, …), or
/// the versioned name for the packages without one. `arch` is `std::env::consts::ARCH`.
pub fn asset_name(kind: &InstallKind, arch: &str, version: &str) -> Option<String> {
let linux = match arch {
"x86_64" | "aarch64" => Some(arch),
_ => None,
};
let windows = match arch {
"x86_64" => Some("x64"),
"aarch64" => Some("arm64"),
"x86" => Some("x86"),
_ => None,
};
let version = version.trim_start_matches('v');
Some(match kind {
InstallKind::AppImage(_) => format!("solidcraft-linux-{}.AppImage", linux?),
InstallKind::Flatpak(_) => format!("solidcraft-linux-{}.flatpak", linux?),
InstallKind::Snap { .. } => format!("solidcraft-linux-{}.snap", linux?),
InstallKind::Deb => format!("solidcraft-linux-{}.deb", linux?),
InstallKind::Rpm => format!("solidcraft-linux-{}.rpm", linux?),
InstallKind::LinuxTarball => format!("solidcraft-{version}-linux-{}.tar.gz", linux?),
InstallKind::WindowsMsi => format!("solidcraft-windows-{}.msi", windows?),
InstallKind::WindowsPortable => format!("solidcraft-windows-{}-portable.zip", windows?),
// Only x64 has the lone .exe; elsewhere the portable zip is the same thing.
InstallKind::WindowsExe if windows == Some("x64") => format!("SolidCraft-{version}-windows-x64.exe"),
InstallKind::WindowsExe => format!("solidcraft-windows-{}-portable.zip", windows?),
InstallKind::MacApp(_) => "solidcraft-macos-universal.dmg".into(),
InstallKind::Dev | InstallKind::Web | InstallKind::Other => return None,
})
}
// ---- releases ---------------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub struct Asset {
pub name: String,
pub url: String,
pub size: u64,
}
/// The parts of a GitHub release an update needs.
#[derive(Clone, Debug, PartialEq)]
pub struct Release {
pub tag: String,
pub name: String,
/// Release notes (Markdown).
pub notes: String,
/// The release's page.
pub page: String,
pub assets: Vec<Asset>,
}
impl Release {
/// A `releases/latest` answer. Drafts and prereleases are refused (GitHub never returns them
/// there, but the answer comes from the network).
pub fn parse(json: &[u8]) -> Result<Release, FetchError> {
let v: Value = serde_json::from_slice(json).map_err(|e| FetchError::Failed(format!("unexpected answer from GitHub: {e}")))?;
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
if v.get("draft").and_then(Value::as_bool).unwrap_or(false) || v.get("prerelease").and_then(Value::as_bool).unwrap_or(false) {
return Err(FetchError::Failed("the latest release is a prerelease".into()));
}
let tag = s("tag_name");
if parse_version(&tag).is_none() {
return Err(FetchError::Failed(format!("the latest release's tag `{}` isn't a version", tag.chars().take(40).collect::<String>())));
}
let mut notes = s("body");
if notes.len() > MAX_NOTES {
let mut cut = MAX_NOTES;
while !notes.is_char_boundary(cut) {
cut -= 1;
}
notes.truncate(cut);
notes.push_str("\n\n…");
}
let page = Some(s("html_url")).filter(|u| u.starts_with(RELEASE_PREFIX)).unwrap_or_else(|| RELEASES_PAGE.to_string());
let assets = v
.get("assets")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(MAX_ASSETS)
.filter_map(|a| {
let name = a.get("name")?.as_str()?.to_string();
let url = a.get("browser_download_url")?.as_str()?.to_string();
url.starts_with(RELEASE_PREFIX).then(|| Asset { name, url, size: a.get("size").and_then(Value::as_u64).unwrap_or(0) })
})
.collect();
Ok(Release {
name: Some(s("name")).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("SolidCraft {tag}")),
tag,
notes,
page,
assets,
})
}
/// The version without the `v`.
pub fn version(&self) -> &str {
self.tag.trim_start_matches('v')
}
/// The asset that fits `kind` on `arch`, if this release has it.
pub fn asset_for(&self, kind: &InstallKind, arch: &str) -> Option<&Asset> {
let name = asset_name(kind, arch, self.version())?;
self.assets.iter().find(|a| a.name == name)
}
/// What Download opens: the fitting file, else the release page. Package-managed
/// installations get the page (their package manager does the update).
pub fn download_url(&self, kind: &InstallKind, arch: &str) -> &str {
if kind.system_managed() {
return &self.page;
}
self.asset_for(kind, arch).map_or(self.page.as_str(), |a| a.url.as_str())
}
}
// ---- what is remembered -----------------------------------------------------------------------
/// Update bookkeeping, kept with the preferences (not design data). Times are Unix seconds.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Memo {
/// When the last check finished (successfully or not).
pub last_check: f64,
/// The next automatic check: a day after a successful one, an hour after a failed one.
pub next_check: f64,
/// GitHub's rate limit was reached: no request at all before this, not even Check now.
pub rate_limited_until: f64,
/// Skip this version: its button never shows (a later version's does).
pub skipped: String,
/// Remind me later: no button before this time.
pub remind_after: f64,
}
impl Memo {
/// An automatic check is due (a clock set back by more than a day counts as due).
pub fn due(&self, now: f64) -> bool {
now >= self.rate_limited_until && (now >= self.next_check || self.next_check - now > CHECK_INTERVAL_S)
}
/// The Update button shows for `release`.
pub fn offers(&self, release: &Release, current: &str, now: f64) -> bool {
is_newer(&release.tag, current) && !same_version(&self.skipped, &release.tag) && now >= self.remind_after
}
pub fn skip(&mut self, release: &Release) {
self.skipped = release.version().to_string();
}
pub fn remind_later(&mut self, now: f64) {
self.remind_after = now + REMIND_DELAY_S;
}
/// A check finished: the next one is a day later after a success, an hour after a failure,
/// and after a rate limit as long as GitHub said (at least a minute, at most a day).
pub fn checked(&mut self, now: f64, result: &Result<Release, FetchError>) {
self.last_check = now;
self.next_check = now
+ match result {
Ok(_) => CHECK_INTERVAL_S,
Err(FetchError::Failed(_)) => RETRY_AFTER_FAILURE_S,
Err(FetchError::RateLimited { retry_after_s }) => (*retry_after_s as f64).clamp(60.0, CHECK_INTERVAL_S),
};
if let Err(FetchError::RateLimited { .. }) = result {
self.rate_limited_until = self.next_check;
}
}
}
fn same_version(a: &str, b: &str) -> bool {
!a.is_empty() && a.trim_start_matches('v') == b.trim_start_matches('v')
}
/// Now, in Unix seconds (works in the browser too).
pub fn unix_now() -> f64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64())
}
// ---- the running state ------------------------------------------------------------------------
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Status {
#[default]
Idle,
Checking,
UpToDate,
Failed(String),
}
#[derive(Default)]
pub struct Updater {
/// The installation, looked up at the first frame.
pub kind: Option<InstallKind>,
pub status: Status,
/// The newest release found (whether or not it is offered).
pub release: Option<Release>,
/// The Update window is open.
pub open: bool,
/// The check in flight, and whether Check now started it.
rx: Option<(Receiver<Result<Release, FetchError>>, bool)>,
started: bool,
/// When Check now was last pressed (Unix seconds).
last_manual: f64,
/// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots.
feed: Option<PathBuf>,
}
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;
+281
View File
@@ -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())]);
}
+1
View File
@@ -14,6 +14,7 @@
- [Plastic parts](guide/plastic.md)
- [Import and export](guide/files.md)
- [Keyboard shortcuts](guide/shortcuts.md)
- [Updates and privacy](guide/updates.md)
- [Driving SolidCraft with an AI agent (MCP)](guide/mcp.md)
# Developer guide
+69
View File
@@ -0,0 +1,69 @@
# Updates and privacy
SolidCraft tells you when a newer release is out. It never updates itself without asking, and
the check sends nothing about you or your designs.
## What you see
When a newer stable release exists, an **Update** button appears in the title bar next to
**Report a bug**. It opens a small window with the new version, its release notes and three
choices:
- **Download** opens the file that fits your installation (see the table below) in your
browser, or the release page when there isn't one.
- **Skip this version** hides the button until an even newer release comes out.
- **Remind me later** hides it for three days.
**Preferences ▸ General ▸ Updates** has **Check for updates automatically** (on by default) and
**Check now**, which checks right away and shows the window even for a version you skipped.
## What is sent
One HTTPS request, at start and at most once a day while SolidCraft runs:
```text
GET https://api.github.com/repos/bherbruck/solidcraft/releases/latest
User-Agent: SolidCraft-update-check
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2022-11-28
```
That's all: no version number, no machine or user identifier, no usage data, no cookies. GitHub
sees the request like any other visit to the API (your IP address, the time); see
[GitHub's privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement).
SolidCraft compares the answer with its own version on your computer.
- Offline, or GitHub doesn't answer within 15 seconds: nothing is shown, and the check is tried
again an hour later.
- GitHub's rate limit for anonymous requests (60 an hour per address) is respected: when GitHub
says to wait, SolidCraft waits that long, Check now included.
- Turning off **Check for updates automatically** stops the checks; only **Check now** asks.
- Setting the environment variable `SOLIDCRAFT_UPDATE_CHECK=0` turns all checks off, for
example on managed machines.
Builds you compile yourself, the Flatpak and the browser app (which updates itself) never check.
## Which file Download gets
Releases attach the main packages under fixed names (see [releasing](https://github.com/bherbruck/solidcraft/blob/main/docs/releasing.md)),
and SolidCraft picks the one matching how it was installed:
| Installed as | Detected by | Download | How to update |
|---|---|---|---|
| AppImage | `APPIMAGE` is set | `solidcraft-linux-<arch>.AppImage` | Replace the old AppImage with the new one |
| Flatpak | `FLATPAK_ID` is set | doesn't check (see below) | `flatpak update ai.storyteller.solidcraft` |
| Snap from the Snap Store | `SNAP` is set | release page | `sudo snap refresh solidcraft` (snapd also refreshes by itself) |
| Snap from a downloaded file | `SNAP_REVISION` starts with `x` | `solidcraft-linux-<arch>.snap` | `sudo snap install --dangerous ./solidcraft-linux-<arch>.snap` |
| `.deb` | under `/usr`, dpkg knows `solidcraft` | `solidcraft-linux-<arch>.deb` | `sudo apt install ./solidcraft-linux-<arch>.deb` |
| `.rpm` | under `/usr`, an rpm database exists | `solidcraft-linux-<arch>.rpm` | `sudo dnf install ./solidcraft-linux-<arch>.rpm` |
| `.tar.gz` | anywhere else on Linux | `solidcraft-<version>-linux-<arch>.tar.gz` | Unpack over the old copy |
| Windows installer | under Program Files | `solidcraft-windows-<arch>.msi` | Run the installer |
| Windows portable | `solidcraft-cli.exe` beside it | `solidcraft-windows-<arch>-portable.zip` | Unzip over the old copy |
| Windows `.exe` | anywhere else on Windows | `SolidCraft-<version>-windows-x64.exe` | Replace the old `.exe` |
| macOS | inside `SolidCraft.app` | `solidcraft-macos-universal.dmg` | Drag the new app to Applications |
The window shows the command for Snap, `.deb` and `.rpm` installations, with a Copy button. The
Flatpak has no network permission and Flatpak itself (or your software center) offers its
updates, so it never checks. Release builds compare versions as [semver](https://semver.org): a nightly build counts
as the release it was built after, so it's offered only a newer stable release, and release
candidates are offered their final release.
+1 -1
View File
@@ -123,7 +123,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `ko`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `ko`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
+2 -1
View File
@@ -20,7 +20,8 @@ also attached without the version in their names, for example `solidcraft-window
`solidcraft-linux-x86_64.AppImage`, and listed in `SHA256SUMS.txt`. The README's Download section
links to `https://github.com/bherbruck/solidcraft/releases/latest/download/<name>`, which follows
the release marked latest; nightlies never are. Keep these names stable: renaming one breaks the
README links.
README links and the in-app update check, which picks them by name (`crates/ui-egui/src/update.rs`;
[updates and privacy](book/src/guide/updates.md)).
Project Pages publishes the documentation, browser app and published-release Flatpak
repository under `/solidcraft/`. Do not add a separate owner-site publisher or permanent