Update now: download, verify and install a release in one click (#1118)
Where SolidCraft can replace itself safely, the Update window gets Update now: - AppImage: the new AppImage is downloaded next to the old one, verified and renamed over it; Restart now starts it as SolidCraft exits. - Windows MSI: the verified .msi starts (msiexec /i … /passive) once SolidCraft has closed, after the usual questions about unsaved designs. - Windows portable zip / standalone exe: the running solidcraft.exe is renamed to .old, the new one takes its name (solidcraft-cli.exe too), Restart now; .old goes at the next start. - macOS: the verified .dmg is opened (the app is ad-hoc signed and not notarized, so it isn't replaced behind the user's back). - Flatpak, Snap, deb, rpm, tar.gz: never replaced; the window shows the command. Every package's SHA-256 must match the release's SHA256SUMS.txt (HTTPS, same release); a mismatch deletes the download and changes nothing. With PUBLIC_KEY set in update/install.rs, SHA256SUMS.txt.minisig must also verify (minisign-verify, MIT) with a trusted comment naming the release. release.yml signs the list when the UPDATE_SIGNING_KEY secret exists and fails when the app expects signatures but the secret is missing; docs/releasing.md has the steps. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8258eb63a
commit
d4a7b3abf3
16 files changed
+966
-12
No files matched your search
@@ -429,6 +429,32 @@ jobs:
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Sign the checksum list for in-app updates
|
||||
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
|
||||
# signing"). The trusted comment names the release, which the app checks.
|
||||
env:
|
||||
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
|
||||
run: |
|
||||
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
|
||||
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
|
||||
if [ -z "$UPDATE_SIGNING_KEY" ]; then
|
||||
if [ -n "$public_key" ]; then
|
||||
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
|
||||
exit 0
|
||||
fi
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends minisign
|
||||
umask 077
|
||||
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
|
||||
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
|
||||
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
|
||||
rm -f "$RUNNER_TEMP/update.key"
|
||||
if [ -n "$public_key" ]; then
|
||||
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
|
||||
fi
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
|
||||
Generated
+9
@@ -2019,6 +2019,12 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3593,14 +3599,17 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"pollster 0.4.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 476 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 477 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -192,6 +192,7 @@ matext4cgmath 0.1.0 (Apache-2.0)
|
||||
memchr 2.8.3 (Unlicense OR MIT)
|
||||
memmap2 0.9.11 (MIT OR Apache-2.0)
|
||||
memoffset 0.9.1 (MIT)
|
||||
minisign-verify 0.3.0 (MIT)
|
||||
miniz_oxide 0.8.9 (MIT OR Zlib OR Apache-2.0)
|
||||
miniz_oxide 0.9.1 (MIT OR Zlib OR Apache-2.0)
|
||||
moxcms 0.8.1 (BSD-3-Clause OR Apache-2.0)
|
||||
@@ -7913,6 +7914,70 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
minisign-verify 0.3.0
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2019-2025 Frank Denis
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--
|
||||
|
||||
Code in the src/crypto folder is derived from the rust-crypto project:
|
||||
https://github.com/DaGenix/rust-crypto
|
||||
|
||||
Original ISC license follows:
|
||||
|
||||
Copyright (c) 2006-2009 Graydon Hoare
|
||||
Copyright (c) 2009-2013 Mozilla Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
miniz_oxide 0.8.9, miniz_oxide 0.9.1
|
||||
================================================================================
|
||||
|
||||
@@ -63,6 +63,12 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
|
||||
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
|
||||
{
|
||||
log::error!("update: starting {}: {e}", program.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +120,7 @@ fn services() -> Services {
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
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") {
|
||||
@@ -138,6 +145,40 @@ fn services() -> Services {
|
||||
/// 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;
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
|
||||
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
|
||||
/// `progress` returns false. The caller verifies the file.
|
||||
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::io::{Read, Write};
|
||||
let fail = |e: String| FetchError::Failed(e);
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
|
||||
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
|
||||
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
|
||||
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let mut done = 0u64;
|
||||
loop {
|
||||
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
done += n as u64;
|
||||
if !progress(done, total) {
|
||||
return Err(fail("cancelled".into()));
|
||||
}
|
||||
}
|
||||
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
|
||||
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::time::Duration;
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
@@ -148,13 +189,13 @@ fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError
|
||||
.tls_config(tls)
|
||||
.https_only(true)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(15)))
|
||||
.timeout_global(Some(timeout))
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.user_agent("SolidCraft-update-check")
|
||||
.max_redirects(3)
|
||||
.build()
|
||||
.into();
|
||||
let mut response = agent
|
||||
let response = agent
|
||||
.get(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
@@ -170,7 +211,7 @@ fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError
|
||||
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()))
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
|
||||
|
||||
+11
-1
@@ -725,5 +725,15 @@
|
||||
"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."
|
||||
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
|
||||
"update.update_now": "Jetzt aktualisieren",
|
||||
"update.install_msi": "Installieren (schließt SolidCraft)",
|
||||
"update.download_open": "Herunterladen und öffnen",
|
||||
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
|
||||
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
|
||||
"update.restart": "Jetzt neu starten",
|
||||
"update.restart_later": "Später",
|
||||
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
|
||||
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
|
||||
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket."
|
||||
}
|
||||
+11
-1
@@ -984,5 +984,15 @@
|
||||
"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."
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
|
||||
"update.update_now": "Update now",
|
||||
"update.install_msi": "Install (closes SolidCraft)",
|
||||
"update.download_open": "Download and open",
|
||||
"update.downloading": "Downloading and verifying… {done} of {total} MB",
|
||||
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
|
||||
"update.restart": "Restart now",
|
||||
"update.restart_later": "Later",
|
||||
"update.installer_started": "The installer starts when SolidCraft closes.",
|
||||
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
|
||||
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead."
|
||||
}
|
||||
+11
-1
@@ -984,5 +984,15 @@
|
||||
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
|
||||
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
|
||||
"update.show": "{version} へのアップデートを表示",
|
||||
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。"
|
||||
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
|
||||
"update.update_now": "今すぐ更新",
|
||||
"update.install_msi": "インストール (SolidCraft を終了)",
|
||||
"update.download_open": "ダウンロードして開く",
|
||||
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
|
||||
"update.restart": "今すぐ再起動",
|
||||
"update.restart_later": "後で",
|
||||
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
|
||||
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
|
||||
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
|
||||
}
|
||||
+11
-1
@@ -725,5 +725,15 @@
|
||||
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
|
||||
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
|
||||
"update.show": "{version} 업데이트 보기",
|
||||
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다."
|
||||
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
|
||||
"update.update_now": "지금 업데이트",
|
||||
"update.install_msi": "설치 (SolidCraft 종료)",
|
||||
"update.download_open": "다운로드 후 열기",
|
||||
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
|
||||
"update.restart": "지금 다시 시작",
|
||||
"update.restart_later": "나중에",
|
||||
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
|
||||
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
|
||||
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
|
||||
}
|
||||
+11
-1
@@ -984,5 +984,15 @@
|
||||
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
|
||||
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
|
||||
"update.show": "显示 {version} 更新",
|
||||
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。"
|
||||
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
|
||||
"update.update_now": "立即更新",
|
||||
"update.install_msi": "安装(将关闭 SolidCraft)",
|
||||
"update.download_open": "下载并打开",
|
||||
"update.downloading": "正在下载并校验… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
|
||||
"update.restart": "立即重启",
|
||||
"update.restart_later": "稍后",
|
||||
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
|
||||
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
|
||||
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。"
|
||||
}
|
||||
@@ -20,6 +20,11 @@ web-time = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
ttf-parser = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
# Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional
|
||||
# minisign signature (MIT), and the portable .zip's executables.
|
||||
sha2 = { workspace = true }
|
||||
minisign-verify = "0.3"
|
||||
zip = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
# The offscreen viewport render test opens a device without a window.
|
||||
|
||||
@@ -215,6 +215,8 @@ pub struct Services {
|
||||
pub graphics_path: Option<String>,
|
||||
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
|
||||
pub http_get: Option<update::HttpGet>,
|
||||
/// HTTPS download to a file, with progress, for one-click updates (`update::install`).
|
||||
pub http_download: Option<update::install::HttpDownload>,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
|
||||
@@ -488,6 +488,12 @@ pub struct Updater {
|
||||
last_manual: f64,
|
||||
/// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots.
|
||||
feed: Option<PathBuf>,
|
||||
/// The one-click update in progress (`install`).
|
||||
job: Option<(Receiver<Result<install::Outcome, String>>, Arc<install::Progress>)>,
|
||||
/// The last one-click update's result.
|
||||
pub installed: Option<Result<install::Outcome, String>>,
|
||||
/// A program for the host to start as SolidCraft exits (restart, installer).
|
||||
launch: Option<(PathBuf, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
@@ -510,10 +516,16 @@ pub fn available(app: &mut SolveApp) -> bool {
|
||||
|
||||
/// Per frame: the first frame starts a due automatic check; a finished check is collected.
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
poll_install(app);
|
||||
// Quitting to restart or install was cancelled (a "save changes?" answered Cancel).
|
||||
if app.updater.launch.is_some() && app.docs.quitting.is_none() && !app.quit_requested {
|
||||
app.updater.launch = None;
|
||||
}
|
||||
if !app.updater.started {
|
||||
app.updater.started = true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
install::clean_up_after_update(std::env::current_exe().ok().as_deref());
|
||||
if std::env::var("SOLIDCRAFT_UPDATE_CHECK").is_ok_and(|v| v == "0") {
|
||||
return;
|
||||
}
|
||||
@@ -605,6 +617,101 @@ fn poll(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- one-click updates ------------------------------------------------------------------------
|
||||
|
||||
/// How this installation updates in place, when the host can download (None: Download only).
|
||||
pub fn install_method(app: &mut SolveApp) -> Option<install::Method> {
|
||||
if app.services.http_get.is_none() || app.services.http_download.is_none() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let exe = std::env::current_exe().ok();
|
||||
install::method(app.updater.kind(), exe.as_deref())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Download, verify and apply the release found (Update now).
|
||||
pub fn start_install(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let (Some(release), Some(method), Some(get), Some(download)) =
|
||||
(app.updater.release.clone(), install_method(app), app.services.http_get.clone(), app.services.http_download.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if app.updater.job.is_some() {
|
||||
return;
|
||||
}
|
||||
let kind = app.updater.kind().clone();
|
||||
let progress = Arc::new(install::Progress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app.updater.installed = None;
|
||||
app.updater.job = Some((rx, progress.clone()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
let staging = std::env::temp_dir().join("solidcraft-update");
|
||||
let net = install::Net { get, download };
|
||||
let spawned = std::thread::Builder::new().name("update-install".into()).spawn(move || {
|
||||
let result = install::run(&release, &kind, &method, std::env::consts::ARCH, &net, &staging, &progress);
|
||||
let _ = tx.send(result);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
app.updater.job = None;
|
||||
app.updater.installed = Some(Err(e.to_string()));
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (ctx, tx, release, method, get, download, kind);
|
||||
app.updater.job = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_install(app: &mut SolveApp) {
|
||||
let Some((rx, _)) = &app.updater.job else { return };
|
||||
let result = match rx.try_recv() {
|
||||
Ok(r) => r,
|
||||
Err(TryRecvError::Empty) => return,
|
||||
Err(TryRecvError::Disconnected) => Err("the update stopped".into()),
|
||||
};
|
||||
app.updater.job = None;
|
||||
match &result {
|
||||
Ok(install::Outcome::Installer(program, args)) => {
|
||||
// The installer replaces this program: start it once SolidCraft has closed.
|
||||
app.updater.launch = Some((program.clone(), args.clone()));
|
||||
crate::documents::request_quit(app);
|
||||
}
|
||||
Ok(install::Outcome::Opened(dmg)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = std::process::Command::new("open").arg(dmg).spawn();
|
||||
}
|
||||
let _ = dmg;
|
||||
}
|
||||
Ok(install::Outcome::Restart(_)) => {}
|
||||
Err(e) => log::warn!("update: {e}"),
|
||||
}
|
||||
app.updater.installed = Some(result);
|
||||
}
|
||||
|
||||
/// Restart now: close (asking about unsaved designs), then start the new version.
|
||||
pub fn restart(app: &mut SolveApp) {
|
||||
if let Some(Ok(install::Outcome::Restart(program))) = &app.updater.installed {
|
||||
app.updater.launch = Some((program.clone(), Vec::new()));
|
||||
crate::documents::request_quit(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// For the host, as SolidCraft exits: the program to start (the new version, an installer).
|
||||
pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec<String>)> {
|
||||
app.updater.launch.take()
|
||||
}
|
||||
|
||||
// ---- user interface ---------------------------------------------------------------------------
|
||||
|
||||
/// The application bar's Update button, right of `right`; returns its rect (None: not shown).
|
||||
@@ -652,6 +759,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
return;
|
||||
};
|
||||
let kind = app.updater.kind().clone();
|
||||
let install = install_method(app);
|
||||
let arch = std::env::consts::ARCH;
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
@@ -692,12 +800,67 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
} else if let Some(name) = &asset {
|
||||
} else if let Some(name) = asset.as_ref().filter(|_| app.updater.installed.as_ref().is_none_or(|r| r.is_err())) {
|
||||
ui.label(RichText::new(crate::i18n::format("update.file", &[("file", name)])).size(11.0).color(t.text_dim));
|
||||
}
|
||||
if let Some((_, progress)) = &app.updater.job {
|
||||
let done = progress.done.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = progress.total.load(std::sync::atomic::Ordering::Relaxed).max(1);
|
||||
let mb = |b: u64| format!("{:.1}", b as f64 / 1_048_576.0);
|
||||
ui.add(
|
||||
egui::ProgressBar::new((done as f32 / total as f32).clamp(0.0, 1.0))
|
||||
.text(crate::i18n::format("update.downloading", &[("done", &mb(done)), ("total", &mb(total))])),
|
||||
);
|
||||
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
|
||||
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
return;
|
||||
}
|
||||
match &app.updater.installed {
|
||||
Some(Ok(install::Outcome::Restart(_))) => {
|
||||
ui.label(RichText::new(crate::i18n::format("update.installed", &[("version", release.version())])).strong());
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(crate::i18n::tr("update.restart")).strong()).fill(t.accent));
|
||||
crate::scenario::publish_rect("update:restart", r.rect);
|
||||
if r.clicked() {
|
||||
action = Some("restart");
|
||||
}
|
||||
if ui.button(crate::i18n::tr("update.restart_later")).clicked() {
|
||||
action = Some("close");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(Ok(install::Outcome::Installer(..))) => {
|
||||
ui.label(crate::i18n::tr("update.installer_started"));
|
||||
return;
|
||||
}
|
||||
Some(Ok(install::Outcome::Opened(_))) => {
|
||||
ui.add(egui::Label::new(crate::i18n::tr("update.dmg_opened")).wrap());
|
||||
return;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
ui.add(egui::Label::new(RichText::new(crate::i18n::format("update.install_failed", &[("error", e)])).color(t.error)).wrap());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let one_click = install.is_some() && !matches!(app.updater.installed, Some(Err(_)));
|
||||
if let Some(m) = install.as_ref().filter(|_| one_click) {
|
||||
let label = match m {
|
||||
install::Method::Msi => crate::i18n::tr("update.install_msi"),
|
||||
install::Method::Dmg => crate::i18n::tr("update.download_open"),
|
||||
_ => crate::i18n::tr("update.update_now"),
|
||||
};
|
||||
let r = ui.add(egui::Button::new(RichText::new(label).strong()).fill(t.accent));
|
||||
crate::scenario::publish_rect("update:install", r.rect);
|
||||
if r.clicked() {
|
||||
action = Some("install");
|
||||
}
|
||||
}
|
||||
let download = if kind.system_managed() { crate::i18n::tr("update.release_page") } else { crate::i18n::tr("update.download") };
|
||||
let r = ui.add(egui::Button::new(RichText::new(download).strong()).fill(t.accent));
|
||||
let r = if one_click { ui.button(download) } else { ui.add(egui::Button::new(RichText::new(download).strong()).fill(t.accent)) };
|
||||
crate::scenario::publish_rect("update:download", r.rect);
|
||||
if r.clicked() {
|
||||
action = Some("download");
|
||||
@@ -728,6 +891,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.preferences.update.remind_later(now);
|
||||
app.updater.open = false;
|
||||
}
|
||||
Some("install") => start_install(app, ctx),
|
||||
Some("restart") => restart(app),
|
||||
Some("close") => app.updater.open = false,
|
||||
_ if !open => app.updater.open = false,
|
||||
_ => {}
|
||||
}
|
||||
@@ -955,5 +1121,9 @@ fn markdown(ui: &mut egui::Ui, src: &str, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod install;
|
||||
|
||||
#[cfg(test)]
|
||||
mod install_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,314 @@
|
||||
//! One-click updates (docs/book/src/guide/updates.md): download the package that fits this
|
||||
//! installation, verify it, and put it in place.
|
||||
//!
|
||||
//! Verification is never skipped. The package's SHA-256 must match the release's
|
||||
//! `SHA256SUMS.txt`, fetched over HTTPS from the same release. When SolidCraft is built with an
|
||||
//! update signing key ([`PUBLIC_KEY`]), `SHA256SUMS.txt.minisig` must also be a valid minisign
|
||||
//! signature by that key whose trusted comment names the release, so a compromised download host
|
||||
//! alone can't push a package. Anything that doesn't verify is deleted and nothing is replaced.
|
||||
//!
|
||||
//! What "put in place" means per installation:
|
||||
//! - AppImage: the new AppImage is written next to the old one and renamed over it (atomic on
|
||||
//! one file system; the running copy keeps its old inode), then SolidCraft offers a restart.
|
||||
//! - Windows installer: the `.msi` is started with `msiexec /i … /passive` as SolidCraft exits.
|
||||
//! - Windows portable `.zip` / standalone `.exe`: the running `solidcraft.exe` is renamed to
|
||||
//! `solidcraft.exe.old` (Windows allows renaming a running executable), the new one takes its
|
||||
//! name, and SolidCraft offers a restart; the `.old` file is deleted at the next start.
|
||||
//! - macOS: the `.dmg` is verified and opened; the user drags the app to Applications. SolidCraft
|
||||
//! is ad-hoc signed and not notarized, so replacing the bundle in place would install code the
|
||||
//! system can't vouch for without the user seeing it.
|
||||
//! - Flatpak, Snap, `.deb`, `.rpm`, `.tar.gz`: never replaced; the window shows the command.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{FetchError, InstallKind, Release};
|
||||
|
||||
/// The minisign public key releases are signed with (the base64 line of `minisign.pub`), or None
|
||||
/// while the project has no update signing key: then the SHA-256 check over HTTPS is what
|
||||
/// guards an update. See docs/releasing.md, "Update signing".
|
||||
pub const PUBLIC_KEY: Option<&str> = None;
|
||||
|
||||
/// The checksum list every release carries, and its signature.
|
||||
pub const SUMS: &str = "SHA256SUMS.txt";
|
||||
pub const SUMS_SIG: &str = "SHA256SUMS.txt.minisig";
|
||||
/// No package is bigger than this (they are about 30 MB).
|
||||
pub const MAX_PACKAGE: u64 = 1 << 30;
|
||||
/// Nor a single file inside the portable zip.
|
||||
const MAX_UNZIPPED: u64 = 1 << 30;
|
||||
|
||||
/// The host's HTTPS download to a file: calls `progress(done, total)` as it goes and stops when
|
||||
/// that returns false.
|
||||
pub type HttpDownload = Arc<dyn Fn(&str, &Path, &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), FetchError> + Send + Sync>;
|
||||
|
||||
/// How an installation is updated in place.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
/// Replace this AppImage file.
|
||||
AppImage(PathBuf),
|
||||
/// Run the downloaded `.msi`.
|
||||
Msi,
|
||||
/// Swap the running executable (at this path) for the portable zip's one.
|
||||
PortableZip(PathBuf),
|
||||
/// Swap the running executable (at this path) for the downloaded `.exe`.
|
||||
Exe(PathBuf),
|
||||
/// Open the downloaded disk image.
|
||||
Dmg,
|
||||
}
|
||||
|
||||
/// The in-place update for an installation, if it has one. `exe` is the running executable.
|
||||
pub fn method(kind: &InstallKind, exe: Option<&Path>) -> Option<Method> {
|
||||
match kind {
|
||||
InstallKind::AppImage(path) => Some(Method::AppImage(path.clone())),
|
||||
InstallKind::WindowsMsi => Some(Method::Msi),
|
||||
InstallKind::WindowsPortable => exe.map(|e| Method::PortableZip(e.to_path_buf())),
|
||||
InstallKind::WindowsExe => exe.map(|e| Method::Exe(e.to_path_buf())),
|
||||
InstallKind::MacApp(_) => Some(Method::Dmg),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do once the package is verified and in place.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// The new version replaced this one: start `program` after SolidCraft exits.
|
||||
Restart(PathBuf),
|
||||
/// Start this installer (program, arguments) after SolidCraft exits.
|
||||
Installer(PathBuf, Vec<String>),
|
||||
/// The disk image was opened for the user.
|
||||
Opened(PathBuf),
|
||||
}
|
||||
|
||||
/// `SHA256SUMS.txt` lines (`<hex> <name>` or `<hex> *<name>`): the lowercase hex for `name`.
|
||||
pub fn expected_sha256(sums: &str, name: &str) -> Option<String> {
|
||||
sums.lines().find_map(|line| {
|
||||
let (hash, file) = line.trim().split_once(char::is_whitespace)?;
|
||||
let file = file.trim_start().trim_start_matches('*');
|
||||
(file == name && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_ascii_lowercase())
|
||||
})
|
||||
}
|
||||
|
||||
/// The SHA-256 of a file, in lowercase hex.
|
||||
pub fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
loop {
|
||||
let n = f.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(buf.get(..n).unwrap_or_default());
|
||||
}
|
||||
Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// `sums` is signed by `key` (minisign, prehashed) with a trusted comment naming `tag`.
|
||||
pub fn verify_signature(sums: &[u8], signature: &str, key: &str, tag: &str) -> Result<(), String> {
|
||||
let key = minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("update signing key: {e}"))?;
|
||||
let sig = minisign_verify::Signature::decode(signature).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
|
||||
key.verify(sums, &sig, false).map_err(|e| format!("{SUMS} isn't signed by SolidCraft's release key: {e}"))?;
|
||||
// The comment is covered by the signature: an older release's signed list can't stand in.
|
||||
let comment = sig.trusted_comment();
|
||||
if !comment.split_whitespace().any(|w| w == tag) {
|
||||
return Err(format!("{SUMS_SIG} is for `{comment}`, not {tag}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Progress of a running update, shared with the UI.
|
||||
#[derive(Default)]
|
||||
pub struct Progress {
|
||||
pub done: AtomicU64,
|
||||
pub total: AtomicU64,
|
||||
pub cancel: AtomicBool,
|
||||
}
|
||||
|
||||
/// What the update needs from the host.
|
||||
pub struct Net {
|
||||
pub get: super::HttpGet,
|
||||
pub download: HttpDownload,
|
||||
}
|
||||
|
||||
/// Download, verify and apply `release` for `method`. Runs on a background thread. `staging` is
|
||||
/// a folder for downloads that aren't written next to their target (the `.msi`, the `.dmg`).
|
||||
pub fn run(
|
||||
release: &Release,
|
||||
kind: &InstallKind,
|
||||
method: &Method,
|
||||
arch: &str,
|
||||
net: &Net,
|
||||
staging: &Path,
|
||||
progress: &Progress,
|
||||
) -> Result<Outcome, String> {
|
||||
let asset = release.asset_for(kind, arch).ok_or_else(|| format!("this release has no package for a {} on {arch}", kind.label()))?;
|
||||
if asset.size > MAX_PACKAGE {
|
||||
return Err(format!("{} is too large ({} bytes)", asset.name, asset.size));
|
||||
}
|
||||
let sums_url = release.assets.iter().find(|a| a.name == SUMS).map(|a| a.url.clone()).ok_or_else(|| format!("this release has no {SUMS}"))?;
|
||||
let sums = (net.get)(&sums_url).map_err(|e| format!("{SUMS}: {e}"))?;
|
||||
if let Some(key) = PUBLIC_KEY {
|
||||
let sig_url = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == SUMS_SIG)
|
||||
.map(|a| a.url.clone())
|
||||
.ok_or_else(|| format!("this release isn't signed (no {SUMS_SIG})"))?;
|
||||
let sig = (net.get)(&sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
|
||||
verify_signature(&sums, &String::from_utf8_lossy(&sig), key, &release.tag)?;
|
||||
}
|
||||
let sums = String::from_utf8_lossy(&sums);
|
||||
let expected = expected_sha256(&sums, &asset.name).ok_or_else(|| format!("{SUMS} doesn't list {}", asset.name))?;
|
||||
|
||||
// Where the download goes: beside its target when it replaces a file (one file system, so
|
||||
// the final rename is atomic), else in the staging folder.
|
||||
let part = match method {
|
||||
Method::AppImage(target) => sibling(target, ".update")?,
|
||||
Method::Exe(exe) => sibling(exe, ".update")?,
|
||||
Method::Msi | Method::Dmg | Method::PortableZip(_) => {
|
||||
std::fs::create_dir_all(staging).map_err(|e| format!("{}: {e}", staging.display()))?;
|
||||
staging.join(&asset.name)
|
||||
}
|
||||
};
|
||||
let cleanup = |e: String| {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
e
|
||||
};
|
||||
progress.total.store(asset.size, Ordering::Relaxed);
|
||||
let report = |done: u64, total: Option<u64>| {
|
||||
progress.done.store(done, Ordering::Relaxed);
|
||||
if let Some(t) = total {
|
||||
progress.total.store(t, Ordering::Relaxed);
|
||||
}
|
||||
!progress.cancel.load(Ordering::Relaxed) && done <= MAX_PACKAGE
|
||||
};
|
||||
(net.download)(&asset.url, &part, &report).map_err(|e| cleanup(format!("{}: {e}", asset.name)))?;
|
||||
if progress.cancel.load(Ordering::Relaxed) {
|
||||
return Err(cleanup("cancelled".into()));
|
||||
}
|
||||
let actual = sha256_file(&part).map_err(cleanup)?;
|
||||
if actual != expected {
|
||||
return Err(cleanup(format!("{} doesn't match {SUMS} (got {actual}, expected {expected}); it was deleted", asset.name)));
|
||||
}
|
||||
apply(method, &part).map_err(cleanup)
|
||||
}
|
||||
|
||||
/// Put a verified package in place.
|
||||
pub fn apply(method: &Method, package: &Path) -> Result<Outcome, String> {
|
||||
match method {
|
||||
Method::AppImage(target) => {
|
||||
make_executable(package)?;
|
||||
std::fs::rename(package, target).map_err(|e| format!("replacing {}: {e}", target.display()))?;
|
||||
Ok(Outcome::Restart(target.clone()))
|
||||
}
|
||||
Method::Exe(exe) => {
|
||||
swap(exe, package)?;
|
||||
Ok(Outcome::Restart(exe.clone()))
|
||||
}
|
||||
Method::PortableZip(exe) => {
|
||||
let dir = exe.parent().ok_or_else(|| format!("{} has no folder", exe.display()))?;
|
||||
let name = exe.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "solidcraft.exe".into());
|
||||
let files = [name.as_str(), "solidcraft-cli.exe"];
|
||||
let mut extracted = Vec::new();
|
||||
for file in files {
|
||||
let to = dir.join(format!("{file}.update"));
|
||||
match unzip_one(package, file, &to) {
|
||||
Ok(true) => extracted.push((dir.join(file), to)),
|
||||
Ok(false) if file == name => return Err(format!("the portable zip has no {file}")),
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
for (_, t) in &extracted {
|
||||
let _ = std::fs::remove_file(t);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(package);
|
||||
for (target, new) in &extracted {
|
||||
swap(target, new)?;
|
||||
}
|
||||
Ok(Outcome::Restart(exe.clone()))
|
||||
}
|
||||
Method::Msi => Ok(Outcome::Installer(PathBuf::from("msiexec"), vec!["/i".into(), package.to_string_lossy().to_string(), "/passive".into()])),
|
||||
Method::Dmg => Ok(Outcome::Opened(package.to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with `suffix` appended to its file name, in the same folder.
|
||||
fn sibling(path: &Path, suffix: &str) -> Result<PathBuf, String> {
|
||||
let name = path.file_name().ok_or_else(|| format!("{} has no file name", path.display()))?;
|
||||
Ok(path.with_file_name(format!("{}{suffix}", name.to_string_lossy())))
|
||||
}
|
||||
|
||||
/// Replace `target` (possibly the running executable) with `new`: `target` becomes
|
||||
/// `target.old`, `new` takes its name; on failure the old file is put back.
|
||||
pub fn swap(target: &Path, new: &Path) -> Result<(), String> {
|
||||
let old = sibling(target, ".old")?;
|
||||
let _ = std::fs::remove_file(&old);
|
||||
let had = target.exists();
|
||||
if had {
|
||||
std::fs::rename(target, &old).map_err(|e| format!("moving {} aside: {e}", target.display()))?;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(new, target) {
|
||||
if had {
|
||||
let _ = std::fs::rename(&old, target);
|
||||
}
|
||||
return Err(format!("putting the new {} in place: {e}", target.display()));
|
||||
}
|
||||
// Not running: no need to keep the old one (the running exe's `.old` goes at next start).
|
||||
let _ = std::fs::remove_file(&old);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the zip entry whose file name is `name` (in any folder) to `to`. False: no such entry.
|
||||
fn unzip_one(zip: &Path, name: &str, to: &Path) -> Result<bool, String> {
|
||||
let file = std::fs::File::open(zip).map_err(|e| format!("{}: {e}", zip.display()))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", zip.display()))?;
|
||||
let index = (0..archive.len()).find(|&i| {
|
||||
archive.name_for_index(i).is_some_and(|n| n.rsplit(['/', '\\']).next().is_some_and(|f| f.eq_ignore_ascii_case(name)) && !n.ends_with('/'))
|
||||
});
|
||||
let Some(index) = index else { return Ok(false) };
|
||||
let mut entry = archive.by_index(index).map_err(|e| format!("{}: {e}", zip.display()))?;
|
||||
if entry.size() > MAX_UNZIPPED {
|
||||
return Err(format!("{name} in the zip is too large"));
|
||||
}
|
||||
// The file is closed at the end of this block (before it may be deleted below).
|
||||
let copied = {
|
||||
let mut out = std::fs::File::create(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
let copied = std::io::copy(&mut (&mut entry).take(MAX_UNZIPPED + 1), &mut out).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
out.flush().map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
copied
|
||||
};
|
||||
if copied > MAX_UNZIPPED {
|
||||
let _ = std::fs::remove_file(to);
|
||||
return Err(format!("{name} in the zip is too large"));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// At start: delete the executable a previous update moved aside (`solidcraft.exe.old`).
|
||||
pub fn clean_up_after_update(exe: Option<&Path>) {
|
||||
for file in [exe.map(Path::to_path_buf), exe.and_then(|e| e.parent()).map(|d| d.join("solidcraft-cli.exe"))].into_iter().flatten() {
|
||||
if let Ok(old) = sibling(&file, ".old")
|
||||
&& old.exists()
|
||||
{
|
||||
let _ = std::fs::remove_file(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::install::*;
|
||||
use super::{Asset, FetchError, InstallKind, Release};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A key pair made with `minisign -G -W` for these tests, and its signature of [`SIGNED_SUMS`]
|
||||
/// with the trusted comment "SolidCraft v9.9.9".
|
||||
const TEST_KEY: &str = "RWSnDqhIJ/zGKMSbsGUl4/prZLXqi3PwpEeY/D8gvdsEUeZRO9yWCoSM";
|
||||
const SIGNED_SUMS: &str = "aaaa solidcraft-linux-x86_64.AppImage\n";
|
||||
const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUSnDqhIJ/zGKEyvbowsT8vEGvdv2OL9Rwv4bo2m7pJC54t2UQWXjU0+V7ayPB0Okk1teY53qZSTugz2Dnko35A3Xc1jdNFZCwU=\ntrusted comment: SolidCraft v9.9.9\npCRA0RNYxyt4SSqLbfI3p00xsCE4Rt8ixJWAQfoyb226cz/XdS7A2PRdG7hFmRmfg4h6zmtIt3iRLSKjk6RsAQ==\n";
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-update-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_lists_are_read() {
|
||||
let h = "a".repeat(64);
|
||||
let sums = format!(
|
||||
"{h} solidcraft-linux-x86_64.AppImage\n{} *solidcraft-windows-x64.msi\nnot a line\n{} short.txt\n",
|
||||
"B".repeat(64),
|
||||
"c".repeat(10)
|
||||
);
|
||||
assert_eq!(expected_sha256(&sums, "solidcraft-linux-x86_64.AppImage"), Some(h));
|
||||
assert_eq!(expected_sha256(&sums, "solidcraft-windows-x64.msi"), Some("b".repeat(64)), "binary-mode marker and upper case");
|
||||
assert_eq!(expected_sha256(&sums, "short.txt"), None, "not a SHA-256");
|
||||
assert_eq!(expected_sha256(&sums, "solidcraft-linux-x86_64"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_must_match_the_key_the_list_and_the_release() {
|
||||
assert_eq!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, TEST_KEY, "v9.9.9"), Ok(()));
|
||||
assert!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, TEST_KEY, "v9.9.8").is_err(), "an older release's signed list");
|
||||
assert!(verify_signature(b"bbbb solidcraft-linux-x86_64.AppImage\n", SIGNATURE, TEST_KEY, "v9.9.9").is_err(), "a changed list");
|
||||
let other = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
|
||||
assert!(verify_signature(SIGNED_SUMS.as_bytes(), SIGNATURE, other, "v9.9.9").is_err(), "another key");
|
||||
assert!(verify_signature(SIGNED_SUMS.as_bytes(), "garbage", TEST_KEY, "v9.9.9").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_per_installation() {
|
||||
let exe = Path::new("/x/solidcraft.exe");
|
||||
assert_eq!(method(&InstallKind::AppImage("/a/S.AppImage".into()), Some(exe)), Some(Method::AppImage("/a/S.AppImage".into())));
|
||||
assert_eq!(method(&InstallKind::WindowsMsi, Some(exe)), Some(Method::Msi));
|
||||
assert_eq!(method(&InstallKind::WindowsPortable, Some(exe)), Some(Method::PortableZip(exe.into())));
|
||||
assert_eq!(method(&InstallKind::WindowsExe, Some(exe)), Some(Method::Exe(exe.into())));
|
||||
assert_eq!(method(&InstallKind::MacApp("/A/S.app".into()), Some(exe)), Some(Method::Dmg));
|
||||
for never in [
|
||||
InstallKind::Flatpak("id".into()),
|
||||
InstallKind::Snap { store: true },
|
||||
InstallKind::Snap { store: false },
|
||||
InstallKind::Deb,
|
||||
InstallKind::Rpm,
|
||||
InstallKind::LinuxTarball,
|
||||
InstallKind::Other,
|
||||
InstallKind::Dev,
|
||||
] {
|
||||
assert_eq!(method(&never, Some(exe)), None, "{never:?} is never replaced in place");
|
||||
}
|
||||
}
|
||||
|
||||
const BASE: &str = "https://github.com/bherbruck/solidcraft/releases/download/v9.9.9/";
|
||||
|
||||
fn release_with(assets: &[(&str, usize)]) -> Release {
|
||||
Release {
|
||||
tag: "v9.9.9".into(),
|
||||
name: "SolidCraft v9.9.9".into(),
|
||||
notes: String::new(),
|
||||
page: "https://github.com/bherbruck/solidcraft/releases/tag/v9.9.9".into(),
|
||||
assets: assets.iter().map(|(n, size)| Asset { name: n.to_string(), url: format!("{BASE}{n}"), size: *size as u64 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A network that serves `files` by name.
|
||||
fn net(files: Vec<(String, Vec<u8>)>) -> Net {
|
||||
let files = Arc::new(files);
|
||||
let f2 = files.clone();
|
||||
let find = move |files: &[(String, Vec<u8>)], url: &str| files.iter().find(|(n, _)| url == format!("{BASE}{n}")).map(|(_, b)| b.clone());
|
||||
let find2 = find;
|
||||
Net {
|
||||
get: Arc::new(move |url: &str| find(&files, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))),
|
||||
download: Arc::new(move |url: &str, to: &Path, progress: &dyn Fn(u64, Option<u64>) -> bool| {
|
||||
let body = find2(&f2, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?;
|
||||
let mut file = std::fs::File::create(to).map_err(|e| FetchError::Failed(e.to_string()))?;
|
||||
for chunk in body.chunks(4) {
|
||||
file.write_all(chunk).map_err(|e| FetchError::Failed(e.to_string()))?;
|
||||
if !progress(chunk.len() as u64, Some(body.len() as u64)) {
|
||||
return Err(FetchError::Failed("cancelled".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appimage_is_replaced_only_when_it_verifies() {
|
||||
let dir = scratch("appimage");
|
||||
let target = dir.join("SolidCraft.AppImage");
|
||||
std::fs::write(&target, b"old version").unwrap();
|
||||
let new = b"new version".to_vec();
|
||||
let name = "solidcraft-linux-x86_64.AppImage";
|
||||
let kind = InstallKind::AppImage(target.clone());
|
||||
let m = Method::AppImage(target.clone());
|
||||
let r = release_with(&[(name, new.len()), (SUMS, 100)]);
|
||||
|
||||
// A package that doesn't match the list: deleted, the old one untouched.
|
||||
let wrong = format!("{} {name}\n", hex(b"something else"));
|
||||
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), wrong.into_bytes())]);
|
||||
let err = run(&r, &kind, &m, "x86_64", &n, &dir.join("staging"), &Progress::default()).unwrap_err();
|
||||
assert!(err.contains("doesn't match"), "{err}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
|
||||
assert!(!dir.join("SolidCraft.AppImage.update").exists(), "the bad download is deleted");
|
||||
|
||||
// Not listed at all.
|
||||
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), b"".to_vec())]);
|
||||
assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains("doesn't list"));
|
||||
// No checksum list in the release: refused before downloading.
|
||||
let bare = release_with(&[(name, new.len())]);
|
||||
assert!(run(&bare, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains(SUMS));
|
||||
|
||||
// Cancelled: nothing changes.
|
||||
let good = format!("{} {name}\n", hex(&new));
|
||||
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]);
|
||||
let p = Progress::default();
|
||||
p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &p).is_err());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
|
||||
|
||||
// Verified: renamed over the old one, executable, restart offered.
|
||||
let out = run(&r, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap();
|
||||
assert_eq!(out, Outcome::Restart(target.clone()));
|
||||
assert_eq!(std::fs::read(&target).unwrap(), new);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755);
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).filter(|e| e.file_name().to_string_lossy().ends_with(".update")).count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_portable_zip_swaps_both_executables() {
|
||||
let dir = scratch("portable");
|
||||
let exe = dir.join("solidcraft.exe");
|
||||
std::fs::write(&exe, b"old app").unwrap();
|
||||
std::fs::write(dir.join("solidcraft-cli.exe"), b"old cli").unwrap();
|
||||
let mut zip_bytes = Vec::new();
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_bytes));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
z.add_directory("solidcraft-9.9.9-windows-x64-portable/", o).unwrap();
|
||||
for (n, b) in [("solidcraft.exe", &b"new app"[..]), ("solidcraft-cli.exe", b"new cli"), ("README.md", b"readme")] {
|
||||
z.start_file(format!("solidcraft-9.9.9-windows-x64-portable/{n}"), o).unwrap();
|
||||
z.write_all(b).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
}
|
||||
let name = "solidcraft-windows-x64-portable.zip";
|
||||
let r = release_with(&[(name, zip_bytes.len()), (SUMS, 100)]);
|
||||
let n = net(vec![(name.into(), zip_bytes.clone()), (SUMS.into(), format!("{} {name}\n", hex(&zip_bytes)).into_bytes())]);
|
||||
let out =
|
||||
run(&r, &InstallKind::WindowsPortable, &Method::PortableZip(exe.clone()), "x86_64", &n, &dir.join("staging"), &Progress::default()).unwrap();
|
||||
assert_eq!(out, Outcome::Restart(exe.clone()));
|
||||
assert_eq!(std::fs::read(&exe).unwrap(), b"new app");
|
||||
assert_eq!(std::fs::read(dir.join("solidcraft-cli.exe")).unwrap(), b"new cli");
|
||||
assert!(!dir.join("staging").join(name).exists(), "the zip is removed");
|
||||
// A zip without the app is refused and changes nothing.
|
||||
std::fs::write(dir.join("empty.zip"), {
|
||||
let mut b = Vec::new();
|
||||
zip::ZipWriter::new(std::io::Cursor::new(&mut b)).finish().unwrap();
|
||||
b
|
||||
})
|
||||
.unwrap();
|
||||
assert!(apply(&Method::PortableZip(exe.clone()), &dir.join("empty.zip")).is_err());
|
||||
assert_eq!(std::fs::read(&exe).unwrap(), b"new app");
|
||||
// The moved-aside executable of an update goes at the next start.
|
||||
std::fs::write(dir.join("solidcraft.exe.old"), b"x").unwrap();
|
||||
clean_up_after_update(Some(&exe));
|
||||
assert!(!dir.join("solidcraft.exe.old").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installers_and_disk_images_are_handed_over() {
|
||||
let dir = scratch("msi");
|
||||
let pkg = dir.join("solidcraft-windows-x64.msi");
|
||||
std::fs::write(&pkg, b"msi").unwrap();
|
||||
assert_eq!(
|
||||
apply(&Method::Msi, &pkg).unwrap(),
|
||||
Outcome::Installer("msiexec".into(), vec!["/i".into(), pkg.to_string_lossy().to_string(), "/passive".into()])
|
||||
);
|
||||
assert_eq!(apply(&Method::Dmg, &pkg).unwrap(), Outcome::Opened(pkg.clone()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -17,6 +17,30 @@ choices:
|
||||
**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.
|
||||
|
||||
## Updating in one click
|
||||
|
||||
Where SolidCraft can replace itself safely, the window also has **Update now**:
|
||||
|
||||
| Installed as | Update now does |
|
||||
|---|---|
|
||||
| AppImage | Downloads the new AppImage next to the old one, verifies it, renames it over the old file and offers **Restart now** |
|
||||
| Windows installer (`.msi`) | **Install (closes SolidCraft)**: downloads and verifies the `.msi`, closes SolidCraft (asking about unsaved designs first) and starts the installer |
|
||||
| Windows portable `.zip` or `.exe` | Downloads and verifies the package, swaps in the new `solidcraft.exe` (and `solidcraft-cli.exe`) and offers **Restart now**; the old file is removed at the next start |
|
||||
| macOS | **Download and open**: downloads and verifies the `.dmg` and opens it; drag SolidCraft to Applications to replace the old copy |
|
||||
| Flatpak, Snap, `.deb`, `.rpm`, `.tar.gz` | Nothing: your package manager updates these (the window shows the command), or you unpack the new archive |
|
||||
|
||||
Every download is verified before anything changes: its SHA-256 must match the release's
|
||||
`SHA256SUMS.txt`, fetched over HTTPS from the same release, and, once the project signs its
|
||||
releases, that list must carry a valid signature by SolidCraft's release key that names the
|
||||
release (see [releasing](https://github.com/bherbruck/solidcraft/blob/main/docs/releasing.md#update-signing)).
|
||||
A download that doesn't verify is deleted and the installed copy is left as it was; **Download**
|
||||
still gets the package for a manual install. On macOS SolidCraft opens the disk image rather than
|
||||
replacing the app itself, because the app is ad-hoc signed and not notarized: replacing it in the
|
||||
background would install code macOS can't vouch for without you seeing it.
|
||||
|
||||
Update now needs write access to the installed file: an AppImage in a folder you can't write to
|
||||
fails with a message, and **Download** is the way to update it.
|
||||
|
||||
## What is sent
|
||||
|
||||
One HTTPS request, at start and at most once a day while SolidCraft runs:
|
||||
@@ -28,7 +52,9 @@ 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
|
||||
**Update now** then downloads `SHA256SUMS.txt` (and its signature) and the one package from the
|
||||
release's own `github.com/bherbruck/solidcraft/releases/download/…` links, with the same
|
||||
User-Agent. 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.
|
||||
|
||||
@@ -206,6 +206,47 @@ Renew the credentials before `--expires` by repeating steps 2 and 3. The snap us
|
||||
confinement and only plugs that connect automatically, apart from `removable-media`, which users
|
||||
connect with `sudo snap connect solidcraft:removable-media` (getting-started.md).
|
||||
|
||||
## Update signing
|
||||
|
||||
The app's **Update now** (docs/book/src/guide/updates.md) only installs a package whose SHA-256
|
||||
matches the release's `SHA256SUMS.txt`, fetched over HTTPS. That protects against corrupted and
|
||||
tampered downloads in transit, but not against someone who can upload release assets. Signing
|
||||
the checksum list closes that gap: once `PUBLIC_KEY` in `crates/ui-egui/src/update/install.rs`
|
||||
is set, the app also requires `SHA256SUMS.txt.minisig`, a [minisign](https://jedisct1.github.io/minisign/)
|
||||
signature by that key whose trusted comment names the release (`SolidCraft v<version>`), and
|
||||
refuses to update in place otherwise. The release workflow signs the list when the
|
||||
`UPDATE_SIGNING_KEY` secret exists, and fails when the app expects signatures but the secret is
|
||||
missing.
|
||||
|
||||
To turn it on once (owner):
|
||||
|
||||
1. On a trusted machine with minisign (`sudo apt install minisign`, `brew install minisign` or
|
||||
`winget install minisign`), make a key pair without a password (the workflow can't type one):
|
||||
|
||||
```sh
|
||||
minisign -G -W -p solidcraft-update.pub -s solidcraft-update.key
|
||||
```
|
||||
|
||||
2. Store the secret key as the repository secret and keep an offline backup of it (a lost key
|
||||
means a new key and an app release that carries it; a leaked key means the same, quickly):
|
||||
|
||||
```sh
|
||||
gh secret set UPDATE_SIGNING_KEY -R bherbruck/solidcraft < solidcraft-update.key
|
||||
```
|
||||
|
||||
3. Put the public key (the second line of `solidcraft-update.pub`, starting with `RW`) into
|
||||
`crates/ui-egui/src/update/install.rs`:
|
||||
|
||||
```rust
|
||||
pub const PUBLIC_KEY: Option<&str> = Some("RW…");
|
||||
```
|
||||
|
||||
and land that through a PR. Builds from then on refuse unsigned updates; releases built
|
||||
from then on carry `SHA256SUMS.txt.minisig`.
|
||||
4. Delete `solidcraft-update.key` from the machine once the backup is safe.
|
||||
|
||||
Older builds without the key keep updating with the SHA-256 check alone.
|
||||
|
||||
## Repository setup
|
||||
|
||||
The `release` branch is advanced only for intentional release batches. Restrict its writers to
|
||||
|
||||
Reference in new issue
Block a user