From 57bd4985bfdbe19e8937738adb6ad54db08bba51 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Mon, 26 Oct 2026 01:03:48 +0800 Subject: [PATCH] feat(updater): support macOS app updates --- .github/scripts/collect_release.py | 5 +- .github/scripts/package_macos.py | 6 +- .github/scripts/package_mcp.py | 4 + .github/scripts/package_portable.py | 20 +- .github/scripts/test_release.py | 25 +- .github/workflows/release.yml | 9 +- CONTRIBUTING.md | 36 ++- Cargo.lock | 4 +- crates/app/src/main.rs | 1 + crates/app/src/ui/settings_dialog.rs | 15 +- crates/app/src/ui/settings_dialog/controls.rs | 9 - crates/core/src/installation.rs | 11 +- crates/core/src/update/bundle.rs | 243 ++++++++++++++++++ crates/core/src/update/bundle_tests.rs | 133 ++++++++++ crates/core/src/update/companions.rs | 4 + crates/core/src/update/install.rs | 165 +++++++++++- crates/core/src/update/mod.rs | 3 +- crates/core/src/update/protocol.rs | 33 ++- crates/core/src/update/service.rs | 81 +++--- crates/core/src/update/tests.rs | 40 +++ .../docs/getting-started/installation.md | 26 +- .../src/content/docs/reference/preferences.md | 4 +- .../content/docs/reference/troubleshooting.md | 6 + docs/src/content/docs/reference/updates.md | 49 ++-- .../zh-cn/getting-started/installation.md | 21 +- .../docs/zh-cn/reference/preferences.md | 2 +- .../docs/zh-cn/reference/troubleshooting.md | 5 + .../content/docs/zh-cn/reference/updates.md | 40 ++- 28 files changed, 824 insertions(+), 176 deletions(-) create mode 100644 crates/core/src/update/bundle.rs create mode 100644 crates/core/src/update/bundle_tests.rs diff --git a/.github/scripts/collect_release.py b/.github/scripts/collect_release.py index dd6da618..d15e3566 100644 --- a/.github/scripts/collect_release.py +++ b/.github/scripts/collect_release.py @@ -3,15 +3,14 @@ import hashlib from pathlib import Path -from package_portable import PLATFORMS from release_version import parse def expected_assets(version): parse(version) return sorted( - [f"plotium-{version}-{p}.zip" for p in PLATFORMS.values()] - + [f"plotium-{version}-{p}.dmg" for p in PLATFORMS.values() if p.startswith("macos-")] + [f"plotium-{version}-{p}.zip" for p in ("windows-x86_64", "linux-x86_64")] + + [f"plotium-{version}-macos-{architecture}.dmg" for architecture in ("aarch64", "x86_64")] ) diff --git a/.github/scripts/package_macos.py b/.github/scripts/package_macos.py index f0f35e07..72870ee7 100644 --- a/.github/scripts/package_macos.py +++ b/.github/scripts/package_macos.py @@ -11,7 +11,8 @@ import tempfile from dmg_layout import validate_layout, write_layout -from package_portable import PLATFORMS, package, run +import package_mcp +from package_portable import PLATFORMS, stage, run from release_version import ROOT, apple_versions TARGETS = {"aarch64-apple-darwin": "arm64", "x86_64-apple-darwin": "x86_64"} @@ -160,8 +161,9 @@ def create_dmg(staging, dmg): def create(target, output, skip_build=False, skip_connector_smoke=False): if sys.platform != "darwin": raise RuntimeError("macOS packaging requires macOS and Apple command-line tools") - directory, version = package(target, output, skip_build, skip_connector_smoke) + directory, version = stage(target, output, skip_build) platform = PLATFORMS[target] + package_mcp.package(directory, platform, version, runtime_smoke=not skip_connector_smoke) staging = output / f"dmg-{platform}" if staging.exists(): shutil.rmtree(staging) diff --git a/.github/scripts/package_mcp.py b/.github/scripts/package_mcp.py index da9098da..db6b28f8 100644 --- a/.github/scripts/package_mcp.py +++ b/.github/scripts/package_mcp.py @@ -14,10 +14,14 @@ "plotium_properties_list", "plotium_properties_inspect", "plotium_plan", "plotium_execute", "plotium_document_inspect", "plotium_figure_diagnose", "plotium_render_preview", "plotium_preview_plan", "plotium_table_columns", "plotium_table_read_rows", "plotium_save_status", "plotium_export_status", + "plotium_capabilities_get", "plotium_apply", "plotium_analysis_results_inspect", + "plotium_diffusion_inspect", "plotium_processing_inspect", + "plotium_tasks_get", "plotium_tasks_list", "plotium_tasks_cancel", } UNBOUND_TOOLS = { "plotium_get_state", "plotium_properties_list", "plotium_execute", "plotium_save_status", "plotium_export_status", + "plotium_capabilities_get", "plotium_tasks_get", "plotium_tasks_list", "plotium_tasks_cancel", } diff --git a/.github/scripts/package_portable.py b/.github/scripts/package_portable.py index 09248242..786a163e 100644 --- a/.github/scripts/package_portable.py +++ b/.github/scripts/package_portable.py @@ -1,4 +1,4 @@ -"""Build the existing flat ZIP contract; macOS also reuses its staged files.""" +"""Stage release files and build Windows/Linux ZIPs; macOS uses architecture-specific DMGs.""" import argparse import os from pathlib import Path @@ -23,7 +23,7 @@ def run(*args, **kwargs): return subprocess.run(list(map(str, args)), cwd=ROOT, check=True, **kwargs) -def package(target, output, skip_build=False, skip_connector_smoke=False): +def stage(target, output, skip_build=False): version = cargo_version() platform = PLATFORMS[target] environment = dict(os.environ, PLOTIUM_RELEASE_CHANNEL=parse(version).group(4) or "stable") @@ -54,7 +54,16 @@ def package(target, output, skip_build=False, skip_connector_smoke=False): shutil.copy2(target_dir / target / "release" / (name + suffix), directory) for name in ("LICENSE", "COPYRIGHT.md", "README.md"): shutil.copy2(ROOT / name, directory) - package_mcp.package(directory, platform, version, runtime_smoke=not skip_connector_smoke) + return directory, version + + +def package(target, output, skip_build=False): + platform = PLATFORMS[target] + if platform.startswith("macos-"): + raise ValueError("macOS releases use package_macos.py and architecture-specific DMGs") + directory, version = stage(target, output, skip_build) + suffix = ".exe" if platform.startswith("windows-") else "" + package_mcp.package(directory, platform, version) archive = output / f"plotium-{version}-{platform}.zip" with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zipped: for path in sorted(directory.iterdir()): @@ -65,14 +74,13 @@ def package(target, output, skip_build=False, skip_connector_smoke=False): zipped.extractall(smoke) connector = Path(smoke) / ("plotium-mcp" + suffix) connector.chmod(0o755) - if not skip_connector_smoke: - package_mcp.smoke(connector) + package_mcp.smoke(connector) return directory, version if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--target", choices=PLATFORMS, required=True) + parser.add_argument("--target", choices=[t for t, p in PLATFORMS.items() if not p.startswith("macos-")], required=True) parser.add_argument("--output", type=Path, default=ROOT / "dist") parser.add_argument("--skip-build", action="store_true") args = parser.parse_args() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py index ebf0e982..78282d59 100644 --- a/.github/scripts/test_release.py +++ b/.github/scripts/test_release.py @@ -115,14 +115,19 @@ def test_binary_validation_checks_architecture_system_libraries_and_minos(self): with self.subTest(outputs=outputs), patch("subprocess.check_output", side_effect=outputs), self.assertRaises(ValueError): inspect_binary(binary, "arm64") - def test_collection_requires_the_exact_four_zip_and_two_dmg_names(self): + def test_collection_requires_two_zips_and_two_architecture_dmgs(self): + self.assertEqual(expected_assets("0.2.0"), [ + "plotium-0.2.0-linux-x86_64.zip", "plotium-0.2.0-macos-aarch64.dmg", + "plotium-0.2.0-macos-x86_64.dmg", + "plotium-0.2.0-windows-x86_64.zip", + ]) with tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) for name in expected_assets("0.1.0"): (directory / name).write_bytes(b"release asset") collect(directory, "0.1.0") sums = (directory / "SHA256SUMS").read_text().splitlines() - self.assertEqual(len(sums), 6) + self.assertEqual(len(sums), 4) self.assertEqual([line.split(" ")[1] for line in sums], expected_assets("0.1.0")) (directory / "plotium-0.1.0-macos-aarch64.dmg").unlink() with self.assertRaises(ValueError): @@ -131,13 +136,21 @@ def test_collection_requires_the_exact_four_zip_and_two_dmg_names(self): with self.assertRaises(ValueError): collect(directory, "0.1.0") + def test_collection_rejects_extra_macos_zip_downloads(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + for name in expected_assets("0.2.0") + ["plotium-0.2.0-macos-aarch64.zip"]: + (directory / name).write_bytes(b"package") + with self.assertRaises(ValueError): + collect(directory, "0.2.0") + def test_flat_zip_retains_gui_connector_extension_and_licenses(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - target = "aarch64-apple-darwin" + target = "x86_64-pc-windows-msvc" binaries = root / "target" / target / "release" binaries.mkdir(parents=True) - for name in ("plotium", "plotium-mcp"): + for name in ("plotium.exe", "plotium-mcp.exe"): (binaries / name).write_bytes(b"binary") for name in ("LICENSE", "COPYRIGHT.md", "README.md"): (root / name).write_text(name) @@ -156,9 +169,9 @@ def mcp(directory, platform, version, runtime_smoke=True): patch.dict("os.environ", {"CARGO_TARGET_DIR": str(root / "target")}): package_portable.package(target, root / "dist", skip_build=True) smoke.assert_called_once() - with zipfile.ZipFile(root / "dist/plotium-0.1.0-macos-aarch64.zip") as archive: + with zipfile.ZipFile(root / "dist/plotium-0.1.0-windows-x86_64.zip") as archive: self.assertEqual(set(archive.namelist()), { - "plotium", "plotium-mcp", "plotium.mcpb", "LICENSE", "COPYRIGHT.md", + "plotium.exe", "plotium-mcp.exe", "plotium.mcpb", "LICENSE", "COPYRIGHT.md", "README.md", "THIRD-PARTY-LICENSES.html", "MCP-THIRD-PARTY-LICENSES.html", }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f5f867f..1b285437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,12 +87,17 @@ jobs: run: cargo install cargo-about --version 0.9.1 --locked --features cli - name: Build release binaries run: cargo build --locked --release --target "$TARGET" -p plotium -p plotium-agent - - name: Package macOS app, DMG, and unchanged portable ZIP + - name: Package macOS app and DMG if: runner.os == 'macOS' run: | python3 -m venv "$RUNNER_TEMP/plotium-packaging-python" "$RUNNER_TEMP/plotium-packaging-python/bin/python" -m pip install -r .github/scripts/requirements-packaging.txt "$RUNNER_TEMP/plotium-packaging-python/bin/python" .github/scripts/package_macos.py --target "$TARGET" --skip-build + - name: Test whole-app update using the packaged DMG and shipping helper + if: runner.os == 'macOS' + env: + PLOTIUM_TEST_UPDATE_DMG: ${{ github.workspace }}/dist/plotium-${{ env.VERSION }}-${{ env.PLATFORM }}.dmg + run: cargo test --locked -p plotium-core update::bundle::tests -- --nocapture - name: Package existing portable ZIP if: runner.os != 'macOS' run: python3 .github/scripts/package_portable.py --target "$TARGET" --skip-build @@ -126,7 +131,7 @@ jobs: pattern: release-* path: dist merge-multiple: true - - name: Require four ZIPs and both DMGs; generate and verify checksums + - name: Require Windows/Linux ZIPs and both macOS DMGs; generate and verify checksums env: VERSION: ${{ needs.version.outputs.version }} run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3d783ac..dabef6a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,9 +98,8 @@ job logs, with a status summary on the workflow run. ## Package a macOS application locally -Build a DMG that can be dragged into Applications, together with the unchanged -portable ZIP. On macOS with Python 3.10+, Apple command-line tools, and the matching -Rust target: +Build a DMG that can be dragged into Applications. On macOS with Python 3.10+, +Apple command-line tools, and the matching Rust target: ```sh cargo install cargo-about --version 0.9.1 --locked --features cli @@ -165,19 +164,27 @@ The pinned Python tools only write and inspect Finder layout metadata during packaging/tests; they are not application runtime dependencies. The optional xtask feature reuses the workspace SVG renderer. -Trusted public distribution additionally requires authorized Developer ID -signing with hardened runtime, notarization, stapling, and validation of the -final Internet-downloaded DMG and installed app. Configure credentials and call -Apple's service only with separate authorization; ad-hoc checks cannot replace -that release gate. +The current no-paid-account distribution uses ad-hoc signatures and explicitly +advertises the lack of notarization. Test browser downloads on real Macs and +record any first-launch approval needed under Privacy & Security; do not disable +Gatekeeper or remove quarantine attributes. This is not equivalent to Developer ID +publisher authentication or notarization. If a paid developer membership becomes +available, add Developer ID signing, hardened runtime, notarization, and stapling; +configure credentials and call Apple's service only with separate authorization. + +From 0.2.0, application bundles update from the matching processor's DMG by +replacing the complete app after exit, with rollback on replacement failure. +The Release workflow tests its actual DMG and shipping helper on both macOS +architectures. This does not replace browser-download and GUI launch acceptance. ## Publish a release The [Release workflow](.github/workflows/release.yml) packages the desktop app and native MCP connector for Windows x86-64, Linux x86-64, and macOS -Intel / Apple Silicon. macOS and Windows archives also contain the native +Intel / Apple Silicon. macOS applications and Windows ZIPs also contain the native Claude Desktop extension. The workflow exercises the connector's actual stdio -initialization and tool discovery before and after archive extraction. +initialization and tool discovery in packaged installations. The macOS jobs +also exercise their actual DMGs through the shipping update helper. It rechecks dependency policy before packaging, without rerunning the rest of CI or requiring extra secrets. @@ -204,14 +211,15 @@ and tag rather than moving an existing tag. Use `v0.1.0` for a regular pre-1.0 release. Only `-alpha.N` and `-beta.N` suffixes are supported for prereleases; they also select the updater channel. -The draft includes the existing four portable ZIPs, two macOS DMGs, license -notices, and `SHA256SUMS` covering all six packages. macOS DMGs are ad-hoc signed -and unnotarized; the portable ZIP contract is unchanged. +The draft contains exactly four install packages: Windows and Linux ZIPs, and +Intel and Apple Silicon macOS DMGs. License notices are inside the packages; +`SHA256SUMS` covers all four. Do not publish macOS ZIPs or a universal DMG. +macOS apps are ad-hoc signed and unnotarized. Platform requirements are in the [installation guide](https://plotium.nmrtist.space/getting-started/installation/). For a rehearsal, use **Actions → Release → Run workflow** on the chosen ref; -this builds and validates the same six-package asset set and uploads it as +this builds and validates the same four-package asset set and uploads it as `validated-release`, without creating a release. Per-architecture macOS validation reports are separate Actions artifacts. Failed runs can be retried to finish a draft; published releases require a new version. diff --git a/Cargo.lock b/Cargo.lock index d09f10ce..f16d386a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6190,9 +6190,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b564ba90..00d11655 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -640,6 +640,7 @@ fn main() -> eframe::Result<()> { && let Err(error) = plan.launch(RELAUNCH_REQUESTED.load(Ordering::Relaxed)) { log::error!("failed to launch update helper: {error}"); + plotium_core::update::record_failure(&error); } log::logger().flush(); Ok(()) diff --git a/crates/app/src/ui/settings_dialog.rs b/crates/app/src/ui/settings_dialog.rs index 8b6929f1..a3e59b26 100644 --- a/crates/app/src/ui/settings_dialog.rs +++ b/crates/app/src/ui/settings_dialog.rs @@ -172,16 +172,11 @@ fn render_category( match cat { SettingsCategory::General => { properties::panel::preferences_section(app, SettingsCategory::General.section_id(), ui); - let automatic = !app.session.updates.requires_manual_install(); - ui.add_enabled_ui(automatic, |ui| { - properties::panel::preferences_section( - app, - properties::panel::PREFERENCES_UPDATES_SECTION, - ui, - ); - }) - .response - .on_disabled_hover_text("Install updates from a new DMG for this application bundle."); + properties::panel::preferences_section( + app, + properties::panel::PREFERENCES_UPDATES_SECTION, + ui, + ); update_status_row(ui, &mut app.session.updates); } SettingsCategory::Appearance => { diff --git a/crates/app/src/ui/settings_dialog/controls.rs b/crates/app/src/ui/settings_dialog/controls.rs index 8a1fdc4c..f36bd755 100644 --- a/crates/app/src/ui/settings_dialog/controls.rs +++ b/crates/app/src/ui/settings_dialog/controls.rs @@ -83,15 +83,6 @@ fn empty_state(ui: &mut Ui, text: &str) { } pub(super) fn update_status_row(ui: &mut Ui, updates: &mut UpdateService) { - if updates.requires_manual_install() { - ui.label(format!("Installed version {}.", env!("CARGO_PKG_VERSION"))); - ui.label(updates.status().label()); - ui.hyperlink_to( - "Download Plotium updates", - "https://github.com/nmrtist/plotium/releases", - ); - return; - } setting_row( ui, "Check for updates", diff --git a/crates/core/src/installation.rs b/crates/core/src/installation.rs index b7534d87..8d60a6d0 100644 --- a/crates/core/src/installation.rs +++ b/crates/core/src/installation.rs @@ -12,8 +12,8 @@ pub fn macos_bundle_contents(executable: &Path) -> Option<&Path> { .then_some(contents) } -/// Only macOS application bundles need whole-app replacement by DMG. -pub fn requires_manual_update(executable: &Path) -> bool { +/// Bundle installations select whole-app DMG updates instead of portable ZIPs. +pub fn uses_bundle_updates(executable: &Path) -> bool { cfg!(target_os = "macos") && macos_bundle_contents(executable).is_some() } @@ -35,11 +35,8 @@ mod tests { "/tmp/Plotium.app/Contents/plotium", ] { assert_eq!(macos_bundle_contents(Path::new(path)), None); - assert!(!requires_manual_update(Path::new(path))); + assert!(!uses_bundle_updates(Path::new(path))); } - assert_eq!( - requires_manual_update(executable), - cfg!(target_os = "macos") - ); + assert_eq!(uses_bundle_updates(executable), cfg!(target_os = "macos")); } } diff --git a/crates/core/src/update/bundle.rs b/crates/core/src/update/bundle.rs new file mode 100644 index 00000000..e685e88f --- /dev/null +++ b/crates/core/src/update/bundle.rs @@ -0,0 +1,243 @@ +//! Whole-bundle replacement keeps code signatures, resources and MCP in sync. +#[cfg(any(target_os = "macos", test))] +use std::path::Path; + +#[cfg(any(target_os = "macos", test))] +use super::UpdateError; + +/// Shipping DMGs contain a single 64-bit Mach-O executable per binary. +/// Read its header directly: lipo is a developer-tool shim, not a runtime tool. +#[cfg(any(target_os = "macos", test))] +fn verify_architecture(binary: &Path, expected_cpu: u32) -> Result<(), UpdateError> { + use std::io::Read; + + let mut header = [0_u8; 32]; + std::fs::File::open(binary)?.read_exact(&mut header)?; + let cpu = u32::from_le_bytes([header[4], header[5], header[6], header[7]]); + let file_type = u32::from_le_bytes([header[12], header[13], header[14], header[15]]); + // MH_MAGIC_64 (little endian) and MH_EXECUTE; codesign validates the rest. + if header[..4] != [0xcf, 0xfa, 0xed, 0xfe] || cpu != expected_cpu || file_type != 2 { + return Err(UpdateError::Installation(format!( + "Update executable {} is not a matching 64-bit macOS application", + binary.display() + ))); + } + Ok(()) +} + +/// Both renames stay on the installation filesystem. Keep the old app until +/// the installed copy passes validation; never modify a running Mach-O in place. +#[cfg(any(target_os = "macos", test))] +fn replace( + incoming: &Path, + target: &Path, + validate: impl FnOnce(&Path) -> Result<(), UpdateError>, +) -> Result<(), UpdateError> { + let parent = target + .parent() + .ok_or_else(|| UpdateError::Installation("application has no parent directory".into()))?; + let backup = tempfile::Builder::new() + .prefix(".plotium-rollback-") + .tempdir_in(parent)?; + let old = backup.path().join("Plotium.app"); + std::fs::rename(target, &old)?; + let result = std::fs::rename(incoming, target) + .map_err(UpdateError::Io) + .and_then(|()| validate(target)); + if let Err(error) = result { + if target.exists() { + // Preserve the rejected copy outside the installation before rollback. + if let Err(move_error) = std::fs::rename(target, backup.path().join("rejected.app")) { + let saved = backup.keep(); + return Err(UpdateError::Installation(format!( + "{error}; could not move rejected app: {move_error}; previous app is at {}", + saved.join("Plotium.app").display() + ))); + } + } + if let Err(rollback) = std::fs::rename(&old, target) { + let saved = backup.keep(); + return Err(UpdateError::Installation(format!( + "{error}; rollback failed: {rollback}; previous app is at {}", + saved.join("Plotium.app").display() + ))); + } + return Err(error); + } + if let Err(error) = backup.close() { + eprintln!("Plotium update installed; could not remove the previous app backup: {error}"); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + use std::process::Command; + + fn run(command: &mut Command) -> Result { + let output = command.output()?; + if !output.status.success() { + return Err(UpdateError::Installation(format!( + "{} failed: {}", + command.get_program().to_string_lossy(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } + + fn plist(app: &Path, key: &str) -> Result { + run(Command::new("/usr/libexec/PlistBuddy") + .arg("-c") + .arg(format!("Print :{key}")) + .arg(app.join("Contents/Info.plist"))) + } + + pub(super) fn validate(app: &Path, version: &str) -> Result<(), UpdateError> { + if plist(app, "CFBundleIdentifier")? != "space.nmrtist.plotium" + || plist(app, "CFBundleExecutable")? != "plotium" + || plist(app, "PlotiumCargoVersion")? != version + { + return Err(UpdateError::Installation( + "DMG contains a different application or version".into(), + )); + } + // CPU_ARCH_ABI64 combined with CPU_TYPE_ARM or CPU_TYPE_X86. + let cpu = if cfg!(target_arch = "aarch64") { + 0x0100_000c + } else { + 0x0100_0007 + }; + for name in ["plotium", "plotium-mcp"] { + super::verify_architecture(&app.join("Contents/MacOS").join(name), cpu)?; + } + run(Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(app))?; + Ok(()) + } + + pub(crate) fn prepare( + dmg: &Path, + executable: &Path, + version: &str, + ) -> Result { + let contents = crate::installation::macos_bundle_contents(executable).ok_or_else(|| { + UpdateError::Installation("expected an installed application bundle".into()) + })?; + let app = contents + .parent() + .ok_or_else(|| UpdateError::Installation("application has no parent".into()))?; + let parent = app.parent().ok_or_else(|| { + UpdateError::Installation("application has no installation directory".into()) + })?; + if app.to_string_lossy().contains("/AppTranslocation/") || app.starts_with("/Volumes") { + return Err(UpdateError::Installation("Move Plotium.app to Applications or a writable local folder and relaunch it before updating.".into())); + } + // Discover read-only/permission failures while the GUI can still explain them. + tempfile::Builder::new().prefix(".plotium-write-check-").tempdir_in(parent) + .map_err(|error| UpdateError::Installation(format!( + "Allow Plotium to update apps in System Settings, or move it to a writable folder and retry: {error}" + )))?.close()?; + let staging = dmg + .parent() + .ok_or_else(|| UpdateError::Installation("DMG has no staging directory".into()))?; + let mount = tempfile::Builder::new() + .prefix("plotium-update-mount-") + .tempdir()?; + run(Command::new("/usr/bin/hdiutil") + .args([ + "attach", + "-readonly", + "-nobrowse", + "-noautoopen", + "-mountpoint", + ]) + .arg(mount.path()) + .arg(dmg))?; + let incoming = staging.join("Plotium.app"); + let result = (|| { + let source = mount.path().join("Plotium.app"); + validate(&source, version)?; + run(Command::new("/usr/bin/ditto").arg(&source).arg(&incoming))?; + validate(&incoming, version)?; + std::fs::write(staging.join("bundle-version"), version)?; + Ok(incoming) + })(); + let detach = run(Command::new("/usr/bin/hdiutil") + .arg("detach") + .arg(mount.path())); + // Do not remove a mount directory if the system could not unmount it. + if let Err(error) = detach { + let retained = mount.keep(); + return Err(UpdateError::Installation(format!( + "{error}; mounted update retained at {}", + retained.display() + ))); + } + result + } + + pub(crate) fn apply(incoming: &Path, target: &Path, staging: &Path) -> Result<(), UpdateError> { + let version = std::fs::read_to_string(staging.join("bundle-version"))?; + validate(incoming, &version)?; + let parent = target + .parent() + .ok_or_else(|| UpdateError::Installation("application has no parent".into()))?; + let local = tempfile::Builder::new() + .prefix(".plotium-incoming-") + .tempdir_in(parent)?; + let copy = local.path().join("Plotium.app"); + run(Command::new("/usr/bin/ditto").arg(incoming).arg(©))?; + validate(©, &version)?; + super::replace(©, target, |app| validate(app, &version))?; + if let Err(error) = local.close() { + eprintln!( + "Plotium update installed; could not remove the incoming app directory: {error}" + ); + } + Ok(()) + } + + pub(crate) fn relaunch(app: &Path) -> Result<(), UpdateError> { + run(Command::new("/usr/bin/open").arg("-n").arg(app))?; + Ok(()) + } + + /// A Mach-O must not be replaced until the originating process fully exits. + pub(crate) fn wait_for_exit(pid: &std::ffi::OsStr) -> Result<(), UpdateError> { + let pid: u32 = pid + .to_string_lossy() + .parse() + .map_err(|_| UpdateError::Installation("invalid updater parent process".into()))?; + if pid == 0 { + return Err(UpdateError::Installation( + "invalid updater parent process".into(), + )); + } + for _ in 0..300 { + if !Command::new("/bin/kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()? + .success() + { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(UpdateError::Installation( + "Plotium did not exit; the installed app was left unchanged".into(), + )) + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::{apply, prepare, relaunch, wait_for_exit}; + +#[cfg(test)] +#[path = "bundle_tests.rs"] +mod tests; diff --git a/crates/core/src/update/bundle_tests.rs b/crates/core/src/update/bundle_tests.rs new file mode 100644 index 00000000..a6e6de33 --- /dev/null +++ b/crates/core/src/update/bundle_tests.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +fn executable_architecture_validation_needs_no_developer_tools() { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("plotium"); + for (cpu, other_cpu) in [(0x0100_0007_u32, 0x0100_000c), (0x0100_000c, 0x0100_0007)] { + let mut header = [0_u8; 32]; + header[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); + header[4..8].copy_from_slice(&cpu.to_le_bytes()); + header[12..16].copy_from_slice(&2_u32.to_le_bytes()); + std::fs::write(&binary, header).unwrap(); + verify_architecture(&binary, cpu).unwrap(); + let error = verify_architecture(&binary, other_cpu).unwrap_err(); + assert!(matches!(error, UpdateError::Installation(_))); + assert!(!error.to_string().contains("server")); + // A library with the right CPU is still not a launchable application. + header[12..16].copy_from_slice(&6_u32.to_le_bytes()); + std::fs::write(&binary, header).unwrap(); + assert!(verify_architecture(&binary, cpu).is_err()); + } + for bytes in [ + vec![], + vec![0xcf, 0xfa, 0xed, 0xfe], + vec![0; 32], + [vec![0xca, 0xfe, 0xba, 0xbe], vec![0; 28]].concat(), + ] { + std::fs::write(&binary, bytes).unwrap(); + assert!(verify_architecture(&binary, 0x0100_000c).is_err()); + } + std::fs::remove_file(&binary).unwrap(); + assert!(verify_architecture(&binary, 0x0100_000c).is_err()); +} + +#[test] +fn whole_app_swap_keeps_resources_and_restores_old_app_on_failure() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("安装 space/Plotium.app"); + let incoming = root.path().join("incoming/Plotium.app"); + for (app, content) in [(&target, "old"), (&incoming, "new")] { + std::fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + for name in ["plotium", "plotium-mcp"] { + std::fs::write(app.join("Contents/MacOS").join(name), content).unwrap(); + } + std::fs::write(app.join("Contents/Info.plist"), content).unwrap(); + } + replace(&incoming, &target, |_| Err(UpdateError::Integrity)).unwrap_err(); + assert_eq!( + std::fs::read(target.join("Contents/Info.plist")).unwrap(), + b"old" + ); + assert_eq!( + std::fs::read(target.join("Contents/MacOS/plotium-mcp")).unwrap(), + b"old" + ); + // A failed rename must also restore the old bundle without validating it. + replace(&incoming, &target, |_| { + panic!("missing source cannot be validated") + }) + .unwrap_err(); + assert_eq!( + std::fs::read(target.join("Contents/Info.plist")).unwrap(), + b"old" + ); + std::fs::create_dir_all(&incoming).unwrap(); + std::fs::write(incoming.join("new-resource"), "resource").unwrap(); + replace(&incoming, &target, |app| { + assert!(app.join("new-resource").is_file()); + Ok(()) + }) + .unwrap(); + assert!(!target.join("Contents/Info.plist").exists()); + assert!(target.join("new-resource").exists()); +} + +/// The release job supplies its actual signed DMG. This test is required there, +/// and skipped during ordinary builds that have no macOS packaging artifacts. +#[cfg(target_os = "macos")] +#[test] +fn packaged_dmg_can_replace_an_installed_bundle() { + let Some(dmg) = std::env::var_os("PLOTIUM_TEST_UPDATE_DMG") else { + return; + }; + let root = tempfile::tempdir().unwrap(); + let staging = root.path().join("download"); + std::fs::create_dir(&staging).unwrap(); + let downloaded = staging.join("update.dmg"); + std::fs::copy(dmg, &downloaded).unwrap(); + let target = root.path().join("安装 space/Plotium.app"); + let executable = target.join("Contents/MacOS/plotium"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, b"previous build").unwrap(); + let version = env!("CARGO_PKG_VERSION"); + // An incorrect release version fails before touching the installed copy. + assert!(prepare(&downloaded, &executable, "9999.0.0").is_err()); + assert_eq!(std::fs::read(&executable).unwrap(), b"previous build"); + let incoming = prepare(&downloaded, &executable, version).unwrap(); + assert_eq!(std::fs::read(&executable).unwrap(), b"previous build"); + let helper = staging.join("plotium-update-helper"); + std::fs::copy(incoming.join("Contents/MacOS/plotium"), &helper).unwrap(); + // Execute the shipping helper, not just the in-process directory swap. + let output = std::process::Command::new(&helper) + // Developer-tool shims fail with this unavailable toolchain selection. + .env("DEVELOPER_DIR", root.path().join("no-developer-tools")) + .arg("--plotium-apply-update") + .arg(&incoming) + .arg(&target) + .arg(&staging) + .arg("2147483647") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + super::macos::validate(&target, version).unwrap(); + assert!(target.join("Contents/Resources/plotium.mcpb").is_file()); + let smoke = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.github/scripts/package_mcp.py"); + let output = std::process::Command::new("python3") + .arg(smoke) + .arg("smoke") + .arg(target.join("Contents/MacOS/plotium-mcp")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!staging.exists()); +} diff --git a/crates/core/src/update/companions.rs b/crates/core/src/update/companions.rs index 3fccb27b..5afa433d 100644 --- a/crates/core/src/update/companions.rs +++ b/crates/core/src/update/companions.rs @@ -10,6 +10,10 @@ fn names() -> Vec<&'static str> { "plotium-mcp" }, "MCP-THIRD-PARTY-LICENSES.html", + "THIRD-PARTY-LICENSES.html", + "LICENSE", + "COPYRIGHT.md", + "README.md", ]; if cfg!(any(windows, target_os = "macos")) { names.push("plotium.mcpb"); diff --git a/crates/core/src/update/install.rs b/crates/core/src/update/install.rs index 13e32ed4..4a16abb8 100644 --- a/crates/core/src/update/install.rs +++ b/crates/core/src/update/install.rs @@ -23,22 +23,34 @@ pub struct InstallPlan { /// Extract the verified artifact and copy a standalone helper into its unique /// staging directory. No installed file is touched while Plotium is running. -pub fn prepare(downloaded: &Path) -> Result { - let target = std::env::current_exe()?; - require_portable_target(&target)?; +pub fn prepare(downloaded: &Path, version: &str) -> Result { + let executable = std::env::current_exe()?; let staging = downloaded .parent() .ok_or_else(|| UpdateError::Protocol("update has no staging directory".into()))? .to_owned(); - let new_binary = extract_binary(downloaded, &target)?; - super::companions::extract(downloaded, &staging)?; + #[cfg(target_os = "macos")] + let (target, new_binary) = if crate::installation::uses_bundle_updates(&executable) { + let incoming = super::bundle::prepare(downloaded, &executable, version)?; + let target = crate::installation::macos_bundle_contents(&executable) + .and_then(Path::parent) + .ok_or_else(|| UpdateError::Protocol("invalid application bundle".into()))?; + (target.to_owned(), incoming) + } else { + prepare_portable(downloaded, &executable, &staging)? + }; + #[cfg(not(target_os = "macos"))] + let (target, new_binary) = { + let _ = version; + prepare_portable(downloaded, &executable, &staging)? + }; let helper_name = if cfg!(windows) { "plotium-update-helper.exe" } else { "plotium-update-helper" }; let helper = staging.join(helper_name); - std::fs::copy(&target, &helper)?; + std::fs::copy(&executable, &helper)?; #[cfg(unix)] set_executable(&helper)?; Ok(InstallPlan { @@ -49,6 +61,17 @@ pub fn prepare(downloaded: &Path) -> Result { }) } +fn prepare_portable( + downloaded: &Path, + target: &Path, + staging: &Path, +) -> Result<(PathBuf, PathBuf), UpdateError> { + require_portable_target(target)?; + let new_binary = extract_binary(downloaded, target)?; + super::companions::extract(downloaded, staging)?; + Ok((target.to_owned(), new_binary)) +} + impl InstallPlan { /// Start the detached helper as the final action after the GUI loop ends. pub fn launch(self, relaunch: bool) -> Result<(), UpdateError> { @@ -60,6 +83,7 @@ impl InstallPlan { .arg(&self.new_binary) .arg(&self.target) .arg(&self.staging) + .arg(std::process::id().to_string()) .spawn() .map(|_| ()) .map_err(UpdateError::Io) @@ -88,9 +112,22 @@ pub fn run_helper_from_args() -> Option { return Some(2); }; - Some(match apply_staged_update(&new_binary, &target, &staging) { + #[cfg(target_os = "macos")] + let result = args + .next() + .ok_or_else(|| UpdateError::Protocol("missing updater parent process".into())) + .and_then(|pid| super::bundle::wait_for_exit(&pid)) + .and_then(|()| apply_staged_update(&new_binary, &target, &staging)); + #[cfg(not(target_os = "macos"))] + let result = apply_staged_update(&new_binary, &target, &staging); + + Some(match result { Ok(()) => 0, - Err(_) => 1, + Err(error) => { + eprintln!("Plotium update failed: {error}"); + record_failure(&error); + 1 + } }) } @@ -99,6 +136,23 @@ pub(super) fn apply_staged_update( target: &Path, staging: &Path, ) -> Result<(), UpdateError> { + #[cfg(target_os = "macos")] + if target + .extension() + .is_some_and(|extension| extension == "app") + { + super::bundle::apply(new_binary, target, staging)?; + let relaunch = staging.join(RELAUNCH_MARKER).exists(); + if let Some(message) = finish_bundle_update(staging, || { + if relaunch { + super::bundle::relaunch(target)?; + } + Ok(()) + }) { + record_notice("update-warning.txt", &message); + } + return Ok(()); + } // The parent launched us only after its GUI loop returned, but Windows // can retain the image lock briefly while the process finishes unwinding. let result = (0..100).find_map(|_| match swap_binary(new_binary, target) { @@ -150,6 +204,53 @@ pub(super) fn apply_staged_update( Ok(()) } +/// Replacement has committed. Cleanup must not prevent a requested relaunch, +/// and neither failure means the installed version should be downloaded again. +#[cfg(any(target_os = "macos", test))] +fn finish_bundle_update( + staging: &Path, + relaunch: impl FnOnce() -> Result<(), UpdateError>, +) -> Option { + let mut warnings = Vec::new(); + if let Err(error) = std::fs::remove_dir_all(staging) { + warnings.push(format!("could not clean temporary files: {error}")); + } + if let Err(error) = relaunch() { + warnings.push(format!( + "could not reopen Plotium; open it manually: {error}" + )); + } + (!warnings.is_empty()).then(|| format!("Update installed, but {}", warnings.join("; "))) +} + +#[cfg(test)] +#[test] +fn committed_bundle_update_relaunches_despite_cleanup_failure_and_reports_warnings() { + let root = tempfile::tempdir().unwrap(); + let staging = root.path().join("staging"); + // A non-directory forces cleanup to fail on every supported platform. + std::fs::write(&staging, b"blocked cleanup").unwrap(); + let mut relaunched = false; + let warning = finish_bundle_update(&staging, || { + relaunched = true; + Ok(()) + }) + .unwrap(); + assert!(relaunched); + assert!(warning.starts_with("Update installed, but")); + assert!(warning.contains("could not clean temporary files")); + std::fs::remove_file(&staging).unwrap(); + std::fs::create_dir(&staging).unwrap(); + let warning = finish_bundle_update(&staging, || { + Err(UpdateError::Installation("launch blocked".into())) + }) + .unwrap(); + assert!(!staging.exists()); + assert!(warning.contains("open it manually")); + std::fs::create_dir(&staging).unwrap(); + assert!(finish_bundle_update(&staging, || Ok(())).is_none()); +} + /// Transactionally replace `target`, rolling back if the new copy fails. pub(super) fn swap_binary(new_binary: &Path, target: &Path) -> Result<(), UpdateError> { require_portable_target(target)?; @@ -192,7 +293,7 @@ fn old_path(target: &Path) -> PathBuf { /// so another running Plotium instance can never lose its staging directory. pub fn cleanup_after_restart() { if let Ok(current) = std::env::current_exe() - && !crate::installation::requires_manual_update(¤t) + && !crate::installation::uses_bundle_updates(¤t) { let _ = std::fs::remove_file(old_path(¤t)); } @@ -200,14 +301,56 @@ pub fn cleanup_after_restart() { } fn require_portable_target(target: &Path) -> Result<(), UpdateError> { - if crate::installation::requires_manual_update(target) { + if crate::installation::macos_bundle_contents(target).is_some() { return Err(UpdateError::Protocol( - "Replace the complete Plotium.app using a new DMG; binary-only updates would invalidate the bundle signature.".into(), + "A Plotium.app update must replace the complete bundle from a verified DMG.".into(), )); } Ok(()) } +/// Post-exit failures cannot reach the closed GUI; retain them for next launch. +pub fn record_failure(error: &UpdateError) { + record_notice("update-failure.txt", &error.to_string()); +} + +fn record_notice(file: &str, message: &str) { + eprintln!("Plotium update: {message}"); + let result = (|| -> std::io::Result<()> { + let directory = crate::settings::config_dir() + .ok_or_else(|| std::io::Error::other("no settings directory"))?; + std::fs::create_dir_all(&directory)?; + std::fs::write(directory.join(file), message) + })(); + if let Err(error) = result { + eprintln!("Could not record Plotium update result: {error}"); + } +} + +pub(super) fn take_failure() -> Option { + take_notice("update-failure.txt") +} + +pub(super) fn take_warning() -> Option { + take_notice("update-warning.txt") +} + +fn take_notice(file: &str) -> Option { + let path = crate::settings::config_dir()?.join(file); + match std::fs::read_to_string(&path) { + Ok(message) => { + if let Err(error) = std::fs::remove_file(path) { + eprintln!("Could not clear Plotium update failure: {error}"); + } + Some(message) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => Some(format!( + "Could not read the previous update result: {error}" + )), + } +} + #[cfg(all(test, target_os = "macos"))] #[test] fn bundle_swap_is_rejected_before_touching_the_installed_executable() { diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index 31fbc039..9359f080 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -4,6 +4,7 @@ //! Release metadata comes directly from GitHub. Tests redirect requests to //! self-contained local servers with `PLOTIUM_UPDATE_URL`. +mod bundle; mod channel; mod companions; mod install; @@ -11,7 +12,7 @@ mod protocol; mod service; pub use channel::{UpdateChannel, UpdateChannelSetting}; -pub use install::{InstallPlan, cleanup_after_restart, run_helper_from_args}; +pub use install::{InstallPlan, cleanup_after_restart, record_failure, run_helper_from_args}; pub use protocol::{DEFAULT_SERVER_URL, ReleaseAsset, UpdateError, server_url}; pub use service::{UpdateService, UpdateStatus}; diff --git a/crates/core/src/update/protocol.rs b/crates/core/src/update/protocol.rs index 4574ac5d..eff90eb6 100644 --- a/crates/core/src/update/protocol.rs +++ b/crates/core/src/update/protocol.rs @@ -49,6 +49,8 @@ pub enum UpdateError { Http(String), #[error("update server sent an invalid response: {0}")] Protocol(String), + #[error("couldn't prepare or install the update: {0}")] + Installation(String), #[error("GitHub rate limit reached; retry in {retry_after:?}")] RateLimited { retry_after: Duration }, #[error("invalid version string {version:?}: {source}")] @@ -82,6 +84,16 @@ fn agent() -> ureq::Agent { pub fn check_latest( base_url: &str, channel: UpdateChannel, +) -> Result, UpdateError> { + let bundle = + std::env::current_exe().is_ok_and(|path| crate::installation::uses_bundle_updates(&path)); + check_latest_for_delivery(base_url, channel, bundle) +} + +pub(super) fn check_latest_for_delivery( + base_url: &str, + channel: UpdateChannel, + bundle: bool, ) -> Result, UpdateError> { let base_url = base_url.trim_end_matches('/'); let mut latest: Option<(semver::Version, GithubRelease)> = None; @@ -115,14 +127,21 @@ pub fn check_latest( return Ok(None); }; - let filename = format!( - "plotium-{}-{}-{}.zip", - version, - std::env::consts::OS, - std::env::consts::ARCH - ); + let filename = if bundle { + format!("plotium-{version}-macos-{}.dmg", std::env::consts::ARCH) + } else { + format!( + "plotium-{}-{}-{}.zip", + version, + std::env::consts::OS, + std::env::consts::ARCH + ) + }; let Some(asset) = release.assets.iter().find(|asset| asset.name == filename) else { - return Ok(None); + return Err(UpdateError::Protocol(format!( + "release {} is missing {filename}", + version + ))); }; let sha256 = match asset.digest.as_deref().and_then(sha256_digest) { Some(hash) => hash.to_owned(), diff --git a/crates/core/src/update/service.rs b/crates/core/src/update/service.rs index af5616dd..8864a7c8 100644 --- a/crates/core/src/update/service.rs +++ b/crates/core/src/update/service.rs @@ -15,7 +15,6 @@ const AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); #[derive(Clone, Debug, PartialEq)] pub enum UpdateStatus { Idle, - ManualInstall, Checking, UpToDate, Downloading { @@ -36,6 +35,9 @@ pub enum UpdateStatus { message: String, retry_after: Option, }, + Warning { + message: String, + }, } impl UpdateStatus { @@ -43,7 +45,6 @@ impl UpdateStatus { pub fn label(&self) -> String { match self { UpdateStatus::Idle => String::new(), - UpdateStatus::ManualInstall => "Install updates by replacing Plotium.app from a new DMG. Your projects and preferences are kept.".into(), UpdateStatus::Checking => "Checking for updates…".into(), UpdateStatus::UpToDate => "You're up to date.".into(), UpdateStatus::Downloading { @@ -56,6 +57,7 @@ impl UpdateStatus { format!("Update {version} ready — restart to install.") } UpdateStatus::Failed { message, .. } => format!("Update failed: {message}"), + UpdateStatus::Warning { message } => message.clone(), } } @@ -66,6 +68,7 @@ impl UpdateStatus { | UpdateStatus::Ready { .. } | UpdateStatus::Installed { .. } | UpdateStatus::Failed { .. } + | UpdateStatus::Warning { .. } ) } } @@ -75,7 +78,6 @@ impl UpdateStatus { /// the frame loop drains without blocking. Automatic checks fail silently /// (back to `Idle`); manual checks surface their errors. pub struct UpdateService { - manual_install: bool, status: UpdateStatus, rx: Option>, manual: bool, @@ -86,14 +88,7 @@ pub struct UpdateService { impl UpdateService { pub fn new(settings: &UpdateSettings) -> Self { - let manual_install = std::env::current_exe() - .is_ok_and(|path| crate::installation::requires_manual_update(&path)); - Self::new_with_delivery(settings, manual_install) - } - - fn new_with_delivery(settings: &UpdateSettings, manual_install: bool) -> Self { let mut service = Self { - manual_install, status: UpdateStatus::Idle, rx: None, manual: false, @@ -102,17 +97,23 @@ impl UpdateService { next_auto_check: None, }; service.configure(settings); + if let Some(message) = super::install::take_warning() { + service.status = UpdateStatus::Warning { message }; + service.next_auto_check = None; + } + if let Some(message) = super::install::take_failure() { + service.status = UpdateStatus::Failed { + message, + retry_after: None, + }; + service.next_auto_check = None; + } service } /// Reconcile to a settings snapshot. Idempotent; safe to call on every /// settings edit. Enabling auto-check schedules a near-immediate check. pub fn configure(&mut self, settings: &UpdateSettings) { - if self.manual_install { - self.status = UpdateStatus::ManualInstall; - self.next_auto_check = None; - return; - } let channel = settings.channel.resolve(); let channel_changed = channel != self.channel; self.channel = channel; @@ -146,10 +147,6 @@ impl UpdateService { &self.status } - pub fn requires_manual_install(&self) -> bool { - self.manual_install - } - pub fn is_busy(&self) -> bool { self.rx.is_some() } @@ -163,9 +160,6 @@ impl UpdateService { /// check. Returns true while a check/download is in flight so the caller /// keeps repainting. pub fn tick(&mut self) -> bool { - if self.manual_install { - return false; - } if let Some(rx) = &self.rx { let mut finished = false; while let Ok(status) = rx.try_recv() { @@ -215,10 +209,6 @@ impl UpdateService { /// Extract a downloaded update and prepare its post-exit helper. pub fn install(&mut self) { - if self.manual_install { - self.status = UpdateStatus::ManualInstall; - return; - } if self.rx.is_some() { return; } @@ -228,7 +218,7 @@ impl UpdateService { let (tx, rx) = std::sync::mpsc::channel(); self.rx = Some(rx); thread::spawn(move || { - let status = match super::install::prepare(&path) { + let status = match super::install::prepare(&path, &version) { Ok(plan) => UpdateStatus::Installed { version, plan }, Err(e) => UpdateStatus::Failed { message: e.to_string(), @@ -254,10 +244,6 @@ impl UpdateService { } fn start_check(&mut self, manual: bool) { - if self.manual_install { - self.status = UpdateStatus::ManualInstall; - return; - } if self.rx.is_some() { self.manual = self.manual || manual; return; @@ -351,21 +337,26 @@ mod tests { use crate::update::UpdateChannelSetting; #[test] - fn bundle_never_downloads_or_stages_binary_updates_and_keeps_settings() { - let settings = UpdateSettings::default(); - let mut service = UpdateService::new_with_delivery(&settings, true); - assert!(service.requires_manual_install()); - service.configure(&settings); - service.check_now(); - service.install(); + fn helper_failure_reaches_next_launch_and_waits_for_manual_retry() { + super::super::install::record_failure(&super::super::UpdateError::Integrity); + let mut service = UpdateService::new(&UpdateSettings::default()); + assert!( + matches!(service.status(), UpdateStatus::Failed { message, .. } if message.contains("integrity")) + ); + assert!(service.next_check_delay().is_none()); assert!(!service.tick()); - assert!(!service.is_busy()); - assert_eq!(service.next_check_delay(), None); - assert_eq!(service.status(), &UpdateStatus::ManualInstall); - assert!(settings.auto_check); - let portable = UpdateService::new_with_delivery(&settings, false); - assert!(!portable.requires_manual_install()); - assert!(portable.next_check_delay().is_some()); + assert!(super::super::install::take_failure().is_none()); + let message = "Update installed, but could not reopen Plotium; open it manually"; + let warning = crate::settings::config_dir() + .unwrap() + .join("update-warning.txt"); + std::fs::write(&warning, message).unwrap(); + let mut service = UpdateService::new(&UpdateSettings::default()); + assert!(matches!(service.status(), UpdateStatus::Warning { .. })); + assert_eq!(service.status().label(), message); + assert!(service.next_check_delay().is_none()); + assert!(!service.tick()); + assert!(!warning.exists()); } #[test] diff --git a/crates/core/src/update/tests.rs b/crates/core/src/update/tests.rs index 8844bc93..f9404678 100644 --- a/crates/core/src/update/tests.rs +++ b/crates/core/src/update/tests.rs @@ -118,6 +118,46 @@ fn check_latest_accepts_github_asset_digest_without_fetching_checksums() { assert_eq!(asset.sha256, hash); } +#[test] +fn bundle_delivery_selects_dmg_and_never_falls_back_to_portable_zip() { + let architecture = std::env::consts::ARCH; + let hash = "ab".repeat(32); + let filename = format!( + "plotium-0.2.1-{}-{}", + std::env::consts::OS, + std::env::consts::ARCH + ); + let base = serve_sequence(|base| { + vec![MockResponse::json( + "/releases?per_page=100&page=1", + format!( + r#"[{{"tag_name":"v0.2.1","draft":false,"assets":[ + {{"name":"{filename}.zip","browser_download_url":"{base}/portable.zip","digest":"sha256:{hash}"}}, + {{"name":"plotium-0.2.1-macos-{architecture}.dmg","browser_download_url":"{base}/bundle.dmg","digest":"sha256:{hash}"}} + ]}}]"# + ), + )] + }); + let asset = super::protocol::check_latest_for_delivery(&base, UpdateChannel::Stable, true) + .unwrap() + .unwrap(); + assert!(asset.url.ends_with("/bundle.dmg")); + assert_eq!(asset.sha256, hash); + let base = serve_sequence(|base| { + vec![MockResponse::json( + "/releases?per_page=100&page=1", + format!( + r#"[{{"tag_name":"v0.2.1","draft":false,"assets":[ + {{"name":"{filename}.zip","browser_download_url":"{base}/portable.zip","digest":"sha256:{hash}"}} + ]}}]"# + ), + )] + }); + assert!( + super::protocol::check_latest_for_delivery(&base, UpdateChannel::Stable, true).is_err() + ); +} + #[test] fn check_latest_scans_beyond_one_thousand_releases() { let hash = "ef".repeat(32); diff --git a/docs/src/content/docs/getting-started/installation.md b/docs/src/content/docs/getting-started/installation.md index 99be74cb..3f365c10 100644 --- a/docs/src/content/docs/getting-started/installation.md +++ b/docs/src/content/docs/getting-started/installation.md @@ -23,9 +23,10 @@ These portable packages do not need an installer. | macOS Intel | `macos-x86_64.dmg` | macOS 13+ | | macOS Apple Silicon | `macos-aarch64.dmg` | macOS 13+ | -The release's `SHA256SUMS` file covers all ZIPs and DMGs. The macOS portable -ZIPs remain available under their existing names and contain the flat executables -and MCP companions, not an application bundle. +Choose **Intel Mac** for an Intel processor, or **Apple Silicon (M series)** for +an M-series chip. You can check **Apple menu → About This Mac**. Each Mac has one +matching DMG; macOS ZIPs are not published from 0.2.0 onward. Windows and Linux +each have one ZIP download. `SHA256SUMS` covers all four packages. The current DMGs contain **ad-hoc-signed, unnotarized** apps. This verifies local bundle integrity, not an Apple-identified developer or approval for Internet @@ -35,8 +36,8 @@ distribution. Gatekeeper can block a downloaded copy; see First launch below. The macOS application bundle already includes the MCP connector and Claude Desktop extension. Keep the app intact; do not move files out of its Contents. -For portable ZIP installations, keep `plotium-mcp` (`plotium-mcp.exe` on Windows) -beside the main executable. macOS and Windows ZIPs also include `plotium.mcpb`. +For Windows and Linux ZIP installations, keep `plotium-mcp` (`plotium-mcp.exe` on Windows) +beside the main executable. Windows ZIPs also include `plotium.mcpb`. When you are ready, follow [Connect an AI client](/ai/connect/). Each client only needs to be added once. @@ -51,8 +52,11 @@ may warn before the first run: - **macOS** — follow the actual Gatekeeper message. Current DMGs are not Developer ID signed or notarized, so a local build launching successfully does not establish that a downloaded copy will be accepted. See Apple's - [safety guidance](https://support.apple.com/en-us/102445) before deciding whether - to allow an app. Do not ignore a malware or damaged-app warning. + [safety guidance](https://support.apple.com/en-us/102445). If you trust the + downloaded copy, try opening it once, then use **System Settings → Privacy & + Security → Open Anyway** if offered and confirm the app name. Do not disable + Gatekeeper or remove quarantine attributes. Do not ignore a malware or + damaged-app warning. On first launch Plotium shows a welcome screen. Drag a data file onto the window or use **File → Open File…** to get started — the @@ -79,10 +83,10 @@ them. Deleting the folder resets Plotium to its defaults. ## Updating and uninstalling -For a macOS `.app` installation, quit Plotium with **Command-Q**, download the -new DMG, and replace Plotium.app in Applications. This keeps your projects and -preferences. Bundle installations do not use the portable binary updater. -Portable ZIP installations retain background updates; see [Updates](/reference/updates/). +From Plotium 0.2.0, macOS `.app` and Windows/Linux ZIP installations support +background updates; see [Updates](/reference/updates/). To update a macOS app +manually, quit with **Command-Q**, download the new DMG, and replace Plotium.app +in Applications. Projects and preferences are kept. To uninstall, delete Plotium.app (or the portable application folder) and, if you want a clean slate, the configuration folder above. diff --git a/docs/src/content/docs/reference/preferences.md b/docs/src/content/docs/reference/preferences.md index 7a43ae19..3b64bdc9 100644 --- a/docs/src/content/docs/reference/preferences.md +++ b/docs/src/content/docs/reference/preferences.md @@ -33,8 +33,8 @@ saved preferences except your recent-files list. AI connection preferences are d - **Automatic updates** and **Update channel** — see [Updates](/reference/updates/). This section also shows the installed version, a **Check now** button, and **Restart now** once an update is - ready. macOS `.app` installations instead show a DMG download link; automatic - updates and channel controls are disabled without changing saved preferences. + ready. macOS `.app` installations use the DMG for the installed processor + architecture and replace the complete application after exit. ## Appearance diff --git a/docs/src/content/docs/reference/troubleshooting.md b/docs/src/content/docs/reference/troubleshooting.md index 63b2ffaf..5e153390 100644 --- a/docs/src/content/docs/reference/troubleshooting.md +++ b/docs/src/content/docs/reference/troubleshooting.md @@ -59,6 +59,12 @@ silent by design. Use **Check now** in **Preferences → General** to get an explicit result, and check your update channel — switching to a more stable channel never downgrades, it waits for the next release. +**A macOS app cannot install its update.** Eject the installation DMG and run +Plotium from Applications or a writable local folder. Allow application updates +if macOS asks. A post-exit installation error appears under **Preferences → +General** on the next launch; retry with **Check now** or install a new DMG +manually. See [Updates](/reference/updates/). + **A project opens with a missing-image placeholder.** The embedded resource is missing, damaged, or fails its integrity check. Check **Operation and Diagnostic History**, select the placeholder, and use **Replace Image…**. diff --git a/docs/src/content/docs/reference/updates.md b/docs/src/content/docs/reference/updates.md index 90a72475..f845eaa8 100644 --- a/docs/src/content/docs/reference/updates.md +++ b/docs/src/content/docs/reference/updates.md @@ -3,25 +3,38 @@ title: Updates description: How Plotium checks for, downloads, and installs new versions. --- -## macOS application bundles: replace the app from a DMG +## Automatic updates -Quit Plotium with **Command-Q**, download the new DMG for your processor, and -replace Plotium.app in Applications. Your projects and preferences are kept. -**Preferences → General** links to downloads and shows the installed version; -automatic updates and channel controls are disabled for bundle installations. -The saved preferences are retained for portable installations. +From Plotium 0.2.0 onward, macOS application bundles and Windows/Linux ZIP +installations can update automatically. With **Automatic updates** enabled +(the default), Plotium periodically checks for newer versions, downloads the +matching package, and verifies its SHA-256 against GitHub release metadata. +Downloads happen in the background; installation happens after Plotium exits. -The portable updater replaces individual executables. It is not used inside -`.app` bundles because that would invalidate their signature and leave bundle -metadata out of sync. Current DMGs are ad-hoc signed and unnotarized; see -[Installation](/getting-started/installation/#first-launch) for first-launch conditions. +### macOS application bundles -## Portable ZIP installations +Install Plotium.app in Applications or another writable local folder and eject +the DMG before running it. The updater downloads the DMG for your processor and +checks the new app's identifier, version, architecture, and code signature. +After exit it replaces the complete application, including its MCP connector, +extension, resources, and bundle metadata. A failed replacement attempts to +restore the previous app. Projects and preferences are kept. -Plotium keeps itself up to date. With automatic updates enabled (the default), -it periodically checks for new versions. Updates are downloaded and securely -verified in the background, so you can keep working. They are applied when -Plotium closes and take effect on the next launch. +macOS may ask permission to update applications. If installation is not writable, +move Plotium to a writable local folder or replace it manually from a new DMG. +Copies running directly from a mounted DMG or an App Translocation folder cannot +update themselves. Quit other running copies before installing an update. + +The apps are **ad-hoc signed and unnotarized**. This checks bundle integrity, +not publisher identity or Apple's malware approval. Updates do not disable +Gatekeeper or remove quarantine attributes. If macOS blocks launching a new +copy, follow [First launch](/getting-started/installation/#first-launch). +The first downloaded launch and future macOS policy can still require user action. + +### Windows and Linux ZIP installations + +The updater replaces the executable and its shipped MCP companions and license +notices together. Keep the extracted package files in the same writable folder. ## Checking and restarting @@ -37,7 +50,11 @@ state, offers **Check now** for an immediate manual check (also available as **Restart now** button. Failed background checks (for example, when you are offline) are silent; -only a check you started yourself reports its error. +only a check you started yourself reports its error. An installation failure +after exit is shown under **Preferences → General** on the next launch; use +**Check now** to retry. Keep a manual download available if relaunch is blocked. +If the update was installed but cleanup or relaunch failed, this page shows an +"Update installed" warning instead; open Plotium manually if needed. ## Release channels diff --git a/docs/src/content/docs/zh-cn/getting-started/installation.md b/docs/src/content/docs/zh-cn/getting-started/installation.md index 9ee21fd9..c564f089 100644 --- a/docs/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs/src/content/docs/zh-cn/getting-started/installation.md @@ -23,8 +23,10 @@ Windows 和 Linux 用户解压 ZIP 到任意位置,运行其中的 `plotium` | macOS Intel | `macos-x86_64.dmg` | macOS 13+ | | macOS Apple Silicon | `macos-aarch64.dmg` | macOS 13+ | -发行版的 `SHA256SUMS` 文件覆盖所有 ZIP 和 DMG。macOS 便携 ZIP 保留原有 -名称和内容,包含裸可执行文件及 MCP 配套文件,不包含应用包。 +Intel 处理器选择 **Intel Mac**;M 系列芯片选择 **Apple Silicon(M 系列)**。 +可通过 **Apple 菜单 → 关于本机** 确认。每台 Mac 对应一个 DMG;从 0.2.0 +开始不再发布 macOS ZIP。Windows、Linux 各提供一个 ZIP 下载。 +`SHA256SUMS` 覆盖全部四个安装包。 当前 DMG 内的应用采用 **ad-hoc 签名,未公证**。这能验证本地应用包的完整性, 不代表经过 Apple 认证的开发者身份或互联网分发许可。Gatekeeper 可能阻止 @@ -33,8 +35,8 @@ Windows 和 Linux 用户解压 ZIP 到任意位置,运行其中的 `plotium` ### 可选:连接 AI 客户端 macOS 应用包已包含 MCP 连接器和 Claude Desktop 扩展,请保持应用包完整, -不要移出 Contents 内的文件。使用便携 ZIP 时,请将 `plotium-mcp` -(Windows 下为 `plotium-mcp.exe`)保留在主程序旁。macOS 和 Windows ZIP +不要移出 Contents 内的文件。使用 Windows 或 Linux ZIP 时,请将 `plotium-mcp` +(Windows 下为 `plotium-mcp.exe`)保留在主程序旁。Windows ZIP 也包含 Claude Desktop 扩展 `plotium.mcpb`。 准备好后,按[连接 AI 客户端](/zh-cn/ai/connect/)中的步骤操作。 @@ -49,7 +51,9 @@ macOS 应用包已包含 MCP 连接器和 Claude Desktop 扩展,请保持应 - **macOS**——以 Gatekeeper 的实际提示为准。当前 DMG 未采用 Developer ID 签名,也未公证,本地自建包能启动不能证明下载的副本会被接受。决定是否 允许应用前,请阅读 [Apple 安全说明](https://support.apple.com/zh-cn/102445)。 - 不要忽视恶意软件或应用损坏警告。 + 若信任下载的副本,先尝试打开一次,再在系统提供此选项时前往 + **系统设置 → 隐私与安全性 → 仍要打开**,核对应用名称并确认。 + 不要关闭 Gatekeeper 或移除隔离属性,也不要忽视恶意软件或应用损坏警告。 首次启动后 Plotium 显示欢迎页。把数据文件拖进窗口,或使用 **File → Open File…** 开始——[界面速览](/zh-cn/getting-started/quick-tour/) @@ -75,9 +79,10 @@ macOS 应用包已包含 MCP 连接器和 Claude Desktop 扩展,请保持应 ## 更新与卸载 -macOS `.app` 用户先用 **Command-Q** 退出 Plotium,再下载新 DMG,替换 -Applications 中的 Plotium.app。工程和偏好设置会保留。应用包不使用便携包的 -二进制更新器;便携 ZIP 仍保留后台更新,详见[更新](/zh-cn/reference/updates/)。 +从 Plotium 0.2.0 开始,macOS `.app` 和 Windows/Linux ZIP 安装均支持后台更新, +详见[更新](/zh-cn/reference/updates/)。若需手动更新 macOS 应用,先用 +**Command-Q** 退出,再下载新 DMG,替换 Applications 中的 Plotium.app。 +工程和偏好设置会保留。 卸载时删除 Plotium.app 或便携应用文件夹即可;若想彻底清理,可一并删除 上述配置文件夹。 diff --git a/docs/src/content/docs/zh-cn/reference/preferences.md b/docs/src/content/docs/zh-cn/reference/preferences.md index 1c70e91d..7e5ab38f 100644 --- a/docs/src/content/docs/zh-cn/reference/preferences.md +++ b/docs/src/content/docs/zh-cn/reference/preferences.md @@ -27,7 +27,7 @@ AI 连接启用状态和权限会持久保存,详见下文。 - **Automatic updates** 与 **Update channel**——见 [更新](/zh-cn/reference/updates/)。此区域还显示已安装版本、 **Check now** 按钮,以及更新就绪后的 **Restart now**。macOS `.app` 安装 - 改为显示 DMG 下载链接;自动更新和通道控件不可用,保存的偏好设置保持不变。 + 会使用对应处理器架构的 DMG,并在退出后替换完整应用。 ## Appearance(外观) diff --git a/docs/src/content/docs/zh-cn/reference/troubleshooting.md b/docs/src/content/docs/zh-cn/reference/troubleshooting.md index b8ee61be..c11564d5 100644 --- a/docs/src/content/docs/zh-cn/reference/troubleshooting.md +++ b/docs/src/content/docs/zh-cn/reference/troubleshooting.md @@ -48,6 +48,11 @@ copies** 已开启,历史保存以隐藏文件保留在项目文件旁。 **Preferences → General** 中用 **Check now** 获得明确结果,并检查更新 通道——切换到更稳定的通道不会降级,而是等待该通道的下一个版本。 +**macOS 应用无法安装更新。** 推出安装 DMG,从 Applications 或可写的本地 +文件夹运行 Plotium。若系统询问,请允许更新应用。退出后的安装错误会在下次 +启动的 **偏好设置 → 通用** 中显示,可用 **Check now** 重试,或从新 +DMG 手动安装。见[更新](/zh-cn/reference/updates/)。 + **打开项目后出现缺失图片占位项。** 嵌入资源缺失、损坏,或未通过完整性校验。 请查看 **Operation and Diagnostic History**,选中占位项并使用 **Replace Image…**。 Plotium 会保留页面供继续编辑,但阻止常规保存和导出,避免损坏状态被静默发布;若只需 diff --git a/docs/src/content/docs/zh-cn/reference/updates.md b/docs/src/content/docs/zh-cn/reference/updates.md index 803ebfff..4a5a9a6d 100644 --- a/docs/src/content/docs/zh-cn/reference/updates.md +++ b/docs/src/content/docs/zh-cn/reference/updates.md @@ -3,22 +3,33 @@ title: 更新 description: Plotium 如何检查、下载并安装新版本。 --- -## macOS 应用包:从 DMG 替换应用 +## 自动更新 -先用 **Command-Q** 退出 Plotium,再下载对应处理器的新 DMG,替换 -Applications 中的 Plotium.app。工程和偏好设置会保留。 -**偏好设置 → 通用** 显示已安装版本和下载链接;应用包安装下自动更新和通道 -控件不可用,但保存的偏好设置仍保留,供便携安装使用。 +从 Plotium 0.2.0 开始,macOS 应用包和 Windows/Linux ZIP 安装均支持自动更新。 +启用 **Automatic updates**(默认开启)后,Plotium 会定期检查更高版本, +下载对应安装包,并根据 GitHub 发行版元数据校验 SHA-256。 +下载在后台进行,安装在 Plotium 退出后完成。 -便携更新器会替换单个可执行文件。在 `.app` 内这样更新会破坏签名,并造成 -应用包元数据与程序版本不一致,因此应用包不使用这条更新路径。当前 DMG -采用 ad-hoc 签名且未公证,首次启动条件见[安装](/zh-cn/getting-started/installation/#首次启动)。 +### macOS 应用包 -## 便携 ZIP 安装 +将 Plotium.app 安装到 Applications 或其他可写的本地文件夹,推出 DMG 后再运行。 +更新器下载对应处理器架构的 DMG,核对新应用的标识、版本、架构和代码签名, +退出后整体替换应用,包括 MCP 连接器、扩展、资源和应用包元数据。 +替换失败时会尝试恢复旧应用。工程和偏好设置会保留。 -Plotium 会自动保持最新。启用自动更新(默认开启)后,应用会定期检查新 -版本。更新会在后台下载并经过安全验证,因此你可以继续工作。更新会在 -Plotium 关闭时完成,并在下次启动时生效。 +macOS 可能要求授予更新应用的权限。安装位置不可写时,请将 Plotium 移到可写 +本地文件夹,或从新 DMG 手动替换。直接从已挂载 DMG 或 App Translocation +文件夹运行的副本不能自动更新。安装更新前请退出其他正在运行的副本。 + +应用采用 **ad-hoc 签名且未公证**。这能校验应用包完整性,不能证明发布者身份, +也不代表通过 Apple 恶意软件审查。更新不会关闭 Gatekeeper 或移除隔离属性。 +若 macOS 阻止新副本启动,请按[首次启动](/zh-cn/getting-started/installation/#首次启动) +处理。首次下载启动及未来的 macOS 策略仍可能要求用户操作。 + +### Windows 和 Linux ZIP 安装 + +更新器同步替换可执行文件、随包 MCP 配套文件及许可证说明。 +请将解压后的文件保留在同一可写文件夹中。 ## 检查与重启 @@ -32,7 +43,10 @@ update** 即可立即使用新版本。重启前,Plotium 会先询问如何处 **Restart now** 按钮。 后台检查失败(例如离线时)不会打扰你;只有你手动发起的检查才会显示 -错误信息。 +错误信息。退出后发生的安装错误会在下次启动的 **偏好设置 → 通用** 中显示, +可用 **Check now** 重试。若系统阻止重新启动,可改用手动下载安装。 +若更新已安装,但清理临时文件或重新启动失败,这里会显示“Update installed” +提示;如有需要,请手动打开 Plotium。 ## 发布通道