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:
1 parent
88432d226a
commit
a6928abf48
21 files changed
+2969
-51
No files matched your search
Generated
+225
-3
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 不会自行检查。"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user