From c9247b7cbb2fda8888fe334319b57a1c688bc221 Mon Sep 17 00:00:00 2001 From: lucaslushuo <282139159+lucaslus@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:30:31 +0800 Subject: [PATCH 1/3] feat(updates): restore signed in-app updates with Sparkle Add Software Updates settings with the actual app version, manual checks, persisted daily checks, and a release notes link. Use Sparkle's native download, Ed25519 verification, installation, and relaunch flow. Bundle a pinned, checksum-verified Sparkle framework and verify packaged updater configuration and signatures in CI. Sign and verify the appcast and update archive before release promotion, rejecting missing or tampered signatures. Document migration from v0.4.0/v0.4.1 and add signed-feed regression coverage. Native upgrade/relaunch and Chromium/WebKit settings and editor regressions were verified during implementation. --- .github/workflows/candidate.yml | 4 + .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 18 +++ .gitignore | 1 + README.md | 10 +- README.zh-CN.md | 8 ++ docs/testing/app-updates.md | 51 ++++++++ package.json | 1 + scripts/setup-sparkle.sh | 26 ++++ scripts/test-update-feed.py | 61 +++++++++ scripts/verify-update-bundle.py | 31 +++++ scripts/verify-update-feed.py | 37 ++++++ src-tauri/.gitignore | 4 + src-tauri/Cargo.lock | 49 ++++++++ src-tauri/Cargo.toml | 3 + src-tauri/Info.plist | 26 ++++ src-tauri/build.rs | 13 +- src-tauri/src/lib.rs | 9 +- src-tauri/src/updates.rs | 65 ++++++++++ src-tauri/tauri.conf.json | 6 +- src/components/settings/SettingsDrawer.tsx | 12 +- .../settings/UpdateSettingsSection.tsx | 116 ++++++++++++++++++ src/i18n/locales/en.json | 17 ++- src/i18n/locales/zh.json | 17 ++- src/lib/api.ts | 38 ++++++ 25 files changed, 612 insertions(+), 20 deletions(-) create mode 100644 docs/testing/app-updates.md create mode 100644 scripts/setup-sparkle.sh create mode 100644 scripts/test-update-feed.py create mode 100644 scripts/verify-update-bundle.py create mode 100644 scripts/verify-update-feed.py create mode 100644 src-tauri/Info.plist create mode 100644 src-tauri/src/updates.rs create mode 100644 src/components/settings/UpdateSettingsSection.tsx diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index 66063fa..256b0d1 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -86,6 +86,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prepare verified Sparkle framework + run: bash scripts/setup-sparkle.sh + - name: Verify frontend and backend tests run: | pnpm build @@ -108,6 +111,7 @@ jobs: executable="$app_path/Contents/MacOS/$executable_name" [[ "$installed_version" == "$APP_VERSION" && "$installed_build" == "$APP_BUILD_NUMBER" ]] lipo "$executable" -verify_arch arm64 x86_64 + python3 scripts/verify-update-bundle.py "$app_path" "$executable" > "$RUNNER_TEMP/floatick-startup.log" 2>&1 & app_pid=$! trap 'kill "$app_pid" 2>/dev/null || true' EXIT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a066618..0107a0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,9 @@ jobs: with: workspaces: src-tauri -> target + - name: Prepare verified Sparkle framework + run: bash scripts/setup-sparkle.sh + - name: Cargo check run: cargo check --manifest-path src-tauri/Cargo.toml @@ -109,5 +112,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build Tauri app (binary verification) - run: pnpm tauri build --no-bundle + - name: Build and verify packaged updater + run: | + pnpm tauri build --bundles app + python3 scripts/verify-update-bundle.py src-tauri/target/release/bundle/macos/Floatick.app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1937af7..f843510 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,6 +178,7 @@ jobs: echo "Expected a universal app, found: $architectures" >&2 exit 1 fi + python3 scripts/verify-update-bundle.py "$app_path" hdiutil detach "$mount_point" trap - EXIT @@ -234,6 +235,9 @@ jobs: GH_REPO: ${{ github.repository }} steps: + - name: Check out tagged release tooling + uses: actions/checkout@v7 + - name: Download verified release assets uses: actions/download-artifact@v8 with: @@ -243,6 +247,7 @@ jobs: - name: Generate signed Sparkle appcast env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + APP_VERSION: ${{ needs.verify.outputs.app_version }} shell: bash run: | sparkle_archive="$RUNNER_TEMP/Sparkle-$SPARKLE_VERSION.tar.xz" @@ -262,6 +267,12 @@ jobs: mkdir -p "$sparkle_directory" tar -xJf "$sparkle_archive" -C "$sparkle_directory" + python3 scripts/test-update-feed.py "$sparkle_directory/bin" + + # A promotion retry also downloads the website alias. Publish only + # the immutable versioned archive in the update feed. + rm -f "$RUNNER_TEMP/artifacts/$PRODUCT_NAME-macos-universal.dmg" + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ | "$sparkle_directory/bin/generate_appcast" \ --ed-key-file - \ @@ -274,6 +285,13 @@ jobs: "$RUNNER_TEMP/artifacts" test -s "$RUNNER_TEMP/artifacts/appcast.xml" + # SURequireSignedFeed protects the feed as well as the archive. + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | "$sparkle_directory/bin/sign_update" --ed-key-file - \ + "$RUNNER_TEMP/artifacts/appcast.xml" + python3 scripts/verify-update-feed.py \ + "$RUNNER_TEMP/artifacts/appcast.xml" \ + "$sparkle_directory/bin/sign_update" "$download_prefix" "$APP_VERSION" mkdir -p "$RUNNER_TEMP/pages" cp "$RUNNER_TEMP/artifacts/appcast.xml" \ "$RUNNER_TEMP/pages/appcast.xml" diff --git a/.gitignore b/.gitignore index dd20b51..ce28f14 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ coverage/ dist/ node_modules/ +__pycache__/ src-tauri/target/ *.dmg *.xcarchive diff --git a/README.md b/README.md index 198daa6..490769e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ Download the latest DMG installer from [GitHub Releases](https://github.com/luca > **First-Launch Note for macOS**: > If macOS displays an "unidentified developer" warning, open **System Settings → Privacy & Security**, scroll down to **Security**, and click **Open Anyway** (only required on first launch). +## App Updates + +Open **Settings → Software Updates** (`⌘,`) to check for a new version, read release notes, or enable daily automatic checks. Sparkle displays download progress and verifies Ed25519 signatures before installing and relaunching, with your confirmation. Automatic checks do not automatically install updates. + +Versions 0.4.0 and 0.4.1 do not contain the updater; install the first updater-enabled release manually once. Updates require an internet connection; your todos and notes remain local. Update preferences are stored in macOS user defaults. + ## Shortcuts & Controls | Action | Shortcut / Gesture | Description | @@ -75,7 +81,7 @@ Floatick automatically creates human-readable JSON files in your home directory | `~/.floatick/tags.json` | Reusable color tags shared between todos and notes | | `~/.floatick/settings.json` | Theme (system/light/dark), language, autostart, and stay-on-top | -Your data never leaves your computer. Floatick operates completely offline and collects zero telemetry. +Your todos and notes never leave your computer and work entirely offline. Optional update checks contact the release feed; Floatick collects zero telemetry. ## Development @@ -95,6 +101,8 @@ pnpm install pnpm tauri:dev ``` +The Tauri commands automatically download the pinned, checksum-verified Sparkle framework. Before running Cargo directly, run `pnpm setup:sparkle` once. The updater is available in a packaged `.app`; browser previews and unbundled `tauri dev` show an unavailable state. Build and launch the `.app` to test native update windows. + ### Production Build ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 80a03fc..b007be0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,6 +47,12 @@ Floatick 是一款专为 macOS 设计的原生轻量效率工具。安静常驻 > **macOS 首次打开提示**: > 如打开时提示“来自未受信任的开发者”,请前往 **系统设置 → 隐私与安全**,在“安全性”中点击 **仍要打开** 即可(仅需首次确认一次)。 +## 应用更新 + +打开 **设置 → 软件更新**(`⌘,`),可手动检查新版本、查看更新说明,或开启每天自动检查。Sparkle 原生窗口负责展示下载进度、验证 Ed25519 签名,并在你确认后安装、重启;自动检查不会自动安装。 + +v0.4.0 和 v0.4.1 尚未内置更新组件,需要先手动安装一次包含此功能的新版本。检查更新需要联网,待办和便签数据仍保存在本机。更新偏好保存在 macOS 用户偏好中。 + ## 快捷键与常用交互 | 操作 | 快捷键 / 交互 | 说明 | @@ -95,6 +101,8 @@ pnpm install pnpm tauri:dev ``` +Tauri 命令会自动下载固定版本且经过 SHA-256 校验的 Sparkle 框架。直接运行 Cargo 前,请先执行一次 `pnpm setup:sparkle`。更新功能仅在打包后的 `.app` 中启用,浏览器预览及未打包的 `tauri dev` 会显示不可用提示;测试原生更新窗口请构建并启动 `.app`。 + ### 生产打包 ```bash diff --git a/docs/testing/app-updates.md b/docs/testing/app-updates.md new file mode 100644 index 0000000..7177fe1 --- /dev/null +++ b/docs/testing/app-updates.md @@ -0,0 +1,51 @@ +# App update validation + +The Tauri client uses Sparkle 2.9.2 with an Ed25519-signed feed and archives. The +private production key remains in the protected GitHub production environment. +Unbundled development sessions deliberately report that native updates are unavailable. + +## Local checks + +```sh +pnpm setup:sparkle +pnpm build +cargo test --manifest-path src-tauri/Cargo.toml --locked +pnpm tauri build --bundles app +python3 scripts/verify-update-bundle.py src-tauri/target/release/bundle/macos/Floatick.app +# With the checksum-verified Sparkle tools extracted locally: +python3 scripts/test-update-feed.py /path/to/sparkle/bin +``` + +The feed regression suite generates temporary signing keys. It checks a valid feed, +a modified feed, a same-size modified archive, and an unsigned archive enclosure. +The stable release workflow runs these tests and verifies the real feed and DMG +signatures before promoting the candidate. Bundle checks also run in CI and on the +candidate and stable release assets. + +## Native end-to-end checks performed + +Tested an isolated app copy with a separate bundle identifier, a temporary signing +key, and a localhost feed. No installed user application was replaced. The fixture +kept the production application's executable and changed only bundle metadata and +signatures. Its version change was for testing, not a published release. + +- Open Settings, check for updates, and display the native available-version window. +- Download a signed DMG and reach the ready-to-install window. +- Install and relaunch: the old process exits, a new process starts, and the fixture's + bundle build changes from 13 to 14. +- Check again and confirm the native “You're up to date” result. +- Modify the signed feed and confirm a native signature error prevents the update. +- Toggle automatic checks and confirm the native preference is persisted. + +Chromium and WebKit checks cover the settings unavailable state, actual version +snapshot, manual checks, duplicate-action prevention, preference failures, check +retries, and recovery after a failed settings read. Existing editor regression +checks still cover the empty caret, checklist alignment, nesting, wrapping, and saving. + +## First release with this feature + +Versions 0.4.0 and 0.4.1 do not contain a client updater. Those users must manually +install the first release containing this implementation. The current 0.4.1 feed is +unsigned and is correctly rejected by the new client. Publishing the new release +through the updated workflow will replace it with a signed feed and archive. Do not +turn off signature verification to accept the old feed. diff --git a/package.json b/package.json index e5a5d2d..fdc0d57 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "setup:sparkle": "bash scripts/setup-sparkle.sh", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", diff --git a/scripts/setup-sparkle.sh b/scripts/setup-sparkle.sh new file mode 100644 index 0000000..2bec065 --- /dev/null +++ b/scripts/setup-sparkle.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +# Match the pinned release tooling; never execute an unverified remote script. +SPARKLE_VERSION=2.9.2 +SPARKLE_SHA256=1cb340cbbef04c6c0d162078610c25e2221031d794a3449d89f2f56f4df77c95 +project_dir="$(cd "$(dirname "$0")/.." && pwd)" +framework_dir="$project_dir/src-tauri" +if [[ "$(uname -s)" != Darwin ]]; then + exit 0 +fi +if [[ -s "$framework_dir/Sparkle.framework/Sparkle" && -f "$framework_dir/.sparkle-archive-sha256" ]] && + [[ "$(cat "$framework_dir/.sparkle-archive-sha256")" == "$SPARKLE_SHA256" ]]; then + exit 0 +fi +scratch_dir="$(mktemp -d)" +trap 'rm -rf "$scratch_dir"' EXIT +curl --fail --location --silent --show-error --retry 3 \ + "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" \ + --output "$scratch_dir/Sparkle.tar.xz" +printf '%s %s\n' "$SPARKLE_SHA256" "$scratch_dir/Sparkle.tar.xz" | shasum -a 256 --check +tar -xJf "$scratch_dir/Sparkle.tar.xz" -C "$scratch_dir" +# Preserve framework symlinks and its embedded installer/XPC services. +ditto "$scratch_dir/Sparkle.framework" "$framework_dir/Sparkle.framework" +printf '%s\n' "$SPARKLE_SHA256" > "$framework_dir/.sparkle-archive-sha256" +printf 'Sparkle %s is ready.\n' "$SPARKLE_VERSION" diff --git a/scripts/test-update-feed.py b/scripts/test-update-feed.py new file mode 100644 index 0000000..5897050 --- /dev/null +++ b/scripts/test-update-feed.py @@ -0,0 +1,61 @@ +"""Regression checks with ephemeral keys: python3 scripts/test-update-feed.py SPARKLE_BIN.""" +import base64 +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("feed", Path(__file__).with_name("verify-update-feed.py")) +feed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(feed) +signer = Path(sys.argv.pop(1)) / "sign_update" + + +class SignedFeedTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.key = base64.b64encode(os.urandom(32)).decode() + self.archive = self.root / "Floatick-0.4.2-macos-universal.dmg" + self.archive.write_bytes(b"signed update fixture") + self.xml = self.root / "appcast.xml" + sig = self.sign(self.archive, "-p").strip() + self.xml.write_text(f'''0.4.2''') + self.sign(self.xml) + + def sign(self, path, *args): + return subprocess.check_output([str(signer), "--ed-key-file", "-", *args, str(path)], + input=self.key, text=True) + + def verify(self): + with patch.dict(os.environ, {"SPARKLE_ED_PRIVATE_KEY": self.key}): + feed.verify(self.xml, signer, "https://example.test/", "0.4.2") + + def test_signed_feed_and_archive(self): + self.verify() + + def test_tampered_feed_is_rejected(self): + self.xml.write_text(self.xml.read_text().replace("0.4.2", "0.4.9")) + with self.assertRaises(subprocess.CalledProcessError): + self.verify() + + def test_same_length_tampered_archive_is_rejected(self): + self.archive.write_bytes(b"tamper" + self.archive.read_bytes()[6:]) + with self.assertRaises(subprocess.CalledProcessError): + self.verify() + + def test_unsigned_enclosure_is_rejected(self): + import re + self.xml.write_text(re.sub(r' sparkle:edSignature="[^"]+"', '', self.xml.read_text())) + self.sign(self.xml) + with self.assertRaisesRegex(ValueError, "unsigned"): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-update-bundle.py b/scripts/verify-update-bundle.py new file mode 100644 index 0000000..b6907b5 --- /dev/null +++ b/scripts/verify-update-bundle.py @@ -0,0 +1,31 @@ +"""Fail a release if the packaged updater differs from its trusted configuration.""" +import plistlib +import subprocess +import sys +from pathlib import Path + + +def verify(app: Path): + root = Path(__file__).resolve().parents[1] + expected = plistlib.loads((root / "src-tauri/Info.plist").read_bytes()) + actual = plistlib.loads((app / "Contents/Info.plist").read_bytes()) + for key, value in expected.items(): + if actual.get(key) != value: + raise ValueError(f"Packaged updater setting {key} differs from Info.plist") + framework = app / "Contents/Frameworks/Sparkle.framework" + for path in ["Sparkle", "Resources/Info.plist", "Versions/B/Autoupdate", + "Versions/B/Updater.app/Contents/MacOS/Updater", + "Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader", + "Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer"]: + if not (framework / path).is_file(): + raise ValueError(f"Missing updater component: {path}") + subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True) + executable = app / "Contents/MacOS" / actual["CFBundleExecutable"] + linked = subprocess.check_output(["otool", "-L", str(executable)], text=True) + if "@rpath/Sparkle.framework/" not in linked: + raise ValueError("Application does not link the embedded Sparkle framework") + print("Packaged update configuration, framework, and code signatures verified.") + + +if __name__ == "__main__": + verify(Path(sys.argv[1])) diff --git a/scripts/verify-update-feed.py b/scripts/verify-update-feed.py new file mode 100644 index 0000000..0a5050a --- /dev/null +++ b/scripts/verify-update-feed.py @@ -0,0 +1,37 @@ +"""Validate the feed and verify signatures using the pinned Sparkle signing tool. + +The private key is read by sign_update through stdin; never include it in argv. +Run only after generate_appcast has matched it to the packaged public key. +""" +import os +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def verify(feed: Path, signer: Path, prefix: str, version: str): + key = os.environ["SPARKLE_ED_PRIVATE_KEY"] + subprocess.run([str(signer), "--verify", "--ed-key-file", "-", str(feed)], + input=key, text=True, check=True) + ns = {"s": "http://www.andymatuschak.org/xml-namespaces/sparkle"} + items = ET.parse(feed).findall("./channel/item") + if len(items) != 1 or items[0].findtext("s:shortVersionString", namespaces=ns) != version: + raise ValueError("Expected exactly one update for the release version") + enclosure = items[0].find("enclosure") + name = f"Floatick-{version}-macos-universal.dmg" + archive = feed.parent / name + if enclosure is None or enclosure.get("url") != prefix + name: + raise ValueError("Unexpected update download URL") + if int(enclosure.get("length", "0")) != archive.stat().st_size: + raise ValueError("Update length does not match release artifact") + signature = enclosure.get("{" + ns["s"] + "}edSignature") + if not signature: + raise ValueError("Update archive is unsigned; check the packaged public key") + subprocess.run([str(signer), "--verify", "--ed-key-file", "-", str(archive), signature], + input=key, text=True, check=True) + print("Update feed and release archive signatures verified.") + + +if __name__ == "__main__": + verify(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4]) diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 502406b..2f7b773 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,7 @@ # will have compiled files and executables /target/ /gen/schemas + +# Verified Sparkle dependency, installed by scripts/setup-sparkle.sh +/Sparkle.framework/ +/.sparkle-archive-sha256 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8addc83..9c18ef3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -809,6 +809,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -1090,6 +1096,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-sparkle-updater", ] [[package]] @@ -2300,9 +2307,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2322,6 +2337,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2382,6 +2398,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2405,6 +2434,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3706,6 +3736,25 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-sparkle-updater" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a05317a0041f5dc2a827c151e13a1cde2918bc155c37918a18d26b5bd02f66a" +dependencies = [ + "dispatch", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 673d486..123ba22 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,3 +23,6 @@ tauri-plugin-autostart = "2" tauri-plugin-opener = "2" dirs = "6.0" chrono = { version = "0.4", features = ["serde"] } + +[target.'cfg(target_os = "macos")'.dependencies] +tauri-plugin-sparkle-updater = "=0.2.5" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..b83c24e --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,26 @@ + + + + + SUFeedURL + https://lucaslus.github.io/floatick/appcast.xml + SUPublicEDKey + QV3YrniNCTaEG6ePfUUTvB1bTgxcoOjL4/OG9fRdS9A= + SURequireSignedFeed + + SUVerifyUpdateBeforeExtraction + + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + SUAllowsAutomaticUpdates + + SUAutomaticallyUpdate + + SUEnableSystemProfiling + + SUShowReleaseNotes + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d2e9d7c 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,14 @@ fn main() { - tauri_build::build() + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + // Bundled apps load the embedded framework. Unbundled development and + // test binaries need the verified local framework installed by setup. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + if std::env::var("PROFILE").as_deref() == Ok("debug") { + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + ); + } + } + tauri_build::build() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f3518ca..01d25ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,16 @@ pub mod models; mod panel; pub mod storage; pub mod tray; +mod updates; use tauri::{Manager, WindowEvent}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default(); + #[cfg(target_os = "macos")] + let builder = builder.plugin(tauri_plugin_sparkle_updater::init()); + builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -66,6 +70,9 @@ pub fn run() { commands::set_autostart_enabled, commands::update_tray_count, commands::quit_app, + updates::get_update_settings, + updates::check_for_updates, + updates::set_automatically_checks_for_updates, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..99d4200 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,65 @@ +use serde::Serialize; +use tauri::AppHandle; +#[cfg(target_os = "macos")] +use tauri_plugin_sparkle_updater::SparkleUpdaterExt; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSettings { + current_version: String, + available: bool, + can_check: bool, + automatically_checks: bool, + last_checked_at: Option, +} + +#[tauri::command] +pub async fn get_update_settings(app: AppHandle) -> Result { + let mut settings = UpdateSettings { + current_version: app.package_info().version.to_string(), + available: false, + can_check: false, + automatically_checks: false, + last_checked_at: None, + }; + #[cfg(target_os = "macos")] + if let Some(updater) = app.sparkle_updater() { + settings.available = true; + settings.can_check = updater.can_check_for_updates().map_err(|e| e.to_string())?; + settings.automatically_checks = updater + .automatically_checks_for_updates() + .map_err(|e| e.to_string())?; + settings.last_checked_at = updater + .last_update_check_date() + .map_err(|e| e.to_string())?; + } + Ok(settings) +} + +#[tauri::command] +pub async fn check_for_updates(app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + if let Some(updater) = app.sparkle_updater() { + if !updater.can_check_for_updates().map_err(|e| e.to_string())? { + return Err("update_in_progress".into()); + } + // Sparkle owns the complete native UI, including errors, download + // progress, Ed25519 verification, installation and relaunch consent. + return updater.check_for_updates().map_err(|e| e.to_string()); + } + Err("updater_unavailable".into()) +} + +#[tauri::command] +pub async fn set_automatically_checks_for_updates( + app: AppHandle, + enabled: bool, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + if let Some(updater) = app.sparkle_updater() { + return updater + .set_automatically_checks_for_updates(enabled) + .map_err(|e| e.to_string()); + } + Err("updater_unavailable".into()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ae8d054..46e8a0f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,8 +6,8 @@ "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", - "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeDevCommand": "pnpm setup:sparkle && pnpm dev", + "beforeBuildCommand": "pnpm setup:sparkle && pnpm build" }, "app": { "macOSPrivateApi": true, @@ -42,6 +42,8 @@ ], "macOS": { "bundleVersion": "13", + "frameworks": ["./Sparkle.framework"], + "signingIdentity": "-", "minimumSystemVersion": "10.15" } } diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index cc14d48..c4e70fb 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -11,6 +11,7 @@ import { } from "@phosphor-icons/react"; import { useSettingsStore } from "@/stores/useSettingsStore"; import { api } from "@/lib/api"; +import { UpdateSettingsSection } from "./UpdateSettingsSection"; import type { ThemePreference, LanguagePreference } from "@/types"; interface SettingsDrawerProps { @@ -210,6 +211,8 @@ export const SettingsDrawer: React.FC = ({ + + {/* About & Data */}
@@ -236,15 +239,6 @@ export const SettingsDrawer: React.FC = ({
- {/* Version */} -
- - {t("versionLabel")} - - - v0.4.0 - -
diff --git a/src/components/settings/UpdateSettingsSection.tsx b/src/components/settings/UpdateSettingsSection.tsx new file mode 100644 index 0000000..9629230 --- /dev/null +++ b/src/components/settings/UpdateSettingsSection.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowSquareOut, ArrowsClockwise } from "@phosphor-icons/react"; +import { api, appVersion, type UpdateSettings } from "@/lib/api"; + +export function UpdateSettingsSection() { + const { t, i18n } = useTranslation(); + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const actionPending = useRef(false); + + useEffect(() => { + let disposed = false; + let timer: ReturnType; + const refresh = async () => { + try { + const next = await api.getUpdateSettings(); + if (!disposed) { + setSettings(next); + setError((current) => current === "updateSettingsFailed" ? null : current); + } + } catch { + if (!disposed) setError("updateSettingsFailed"); + } finally { + // Native update windows may outlive this drawer. Read their state only + // while the section is visible, without overlapping native requests. + if (!disposed) timer = setTimeout(refresh, 2000); + } + }; + void refresh(); + return () => { + disposed = true; + clearTimeout(timer); + }; + }, []); + + const runAction = async (action: () => Promise, errorKey: string) => { + if (actionPending.current) return; + actionPending.current = true; + setPending(true); + setError(null); + try { + await action(); + setSettings(await api.getUpdateSettings()); + } catch { + setError(errorKey); + } finally { + actionPending.current = false; + setPending(false); + } + }; + + const busy = pending || (settings?.available && !settings.canCheck); + return ( +
+ + {t("updatesSectionTitle")} + +
+
+
+

{t("currentVersionLabel")}

+

v{settings?.currentVersion ?? appVersion}

+
+ +
+
+
+

{t("autoCheckUpdates")}

+

{t("autoCheckUpdatesDescription")}

+
+ +
+
+
+ {error ?

{t(error)}

: settings && !settings.available ? ( +

{t("updatesUnavailable")}

+ ) : busy ? ( +

{t("nativeUpdateWindowHint")}

+ ) : settings?.lastCheckedAt ? ( +

{t("lastUpdateCheck", { date: new Date(settings.lastCheckedAt).toLocaleString(i18n.language) })}

+ ) : null} + +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ae55eda..5008382 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -89,7 +89,6 @@ "clickToAddContent": "No content. Click to add details…", "doubleClickToEdit": "Double-click to edit", "viewModeHint": "Press E to edit · Esc to close", - "edit": "Edit", "close": "Close", "createTodoAction": "New Todo", "saveChangesAction": "Save", @@ -140,5 +139,19 @@ "link": "Link", "charactersCount": "{{count}} chars", "dateFormatMonthYear": "MMMM yyyy", - "dateFormatMonthDay": "MMM d" + "dateFormatMonthDay": "MMM d", + "updatesSectionTitle": "Software Updates", + "currentVersionLabel": "Current version", + "checkForUpdates": "Check for Updates", + "updateInProgress": "Update in progress", + "autoCheckUpdates": "Check automatically", + "autoCheckUpdatesDescription": "Check daily; ask before installing", + "updateSettingsFailed": "Could not load update settings. Please try again shortly.", + "updateCheckFailed": "Could not start checking. Try again or download from the release page.", + "updatePreferenceFailed": "Could not save your update preference. Please try again.", + "updatesUnavailable": "In-app updates are unavailable in this session. Use the installed Floatick app.", + "nativeUpdateWindowHint": "Use the update window to view the result, download, or install a new version.", + "lastUpdateCheck": "Last checked: {{date}}", + "viewReleaseNotes": "Release notes and downloads", + "openReleaseFailed": "Could not open the release page. Please check your default browser settings." } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f816b2d..6b97db4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -89,7 +89,6 @@ "clickToAddContent": "暂无正文内容,点击进入编辑…", "doubleClickToEdit": "双击进入编辑", "viewModeHint": "按 E 编辑 · Esc 退出", - "edit": "编辑", "close": "关闭", "createTodoAction": "新建待办", "saveChangesAction": "保存", @@ -140,5 +139,19 @@ "link": "链接", "charactersCount": "{{count}} 字", "dateFormatMonthYear": "yyyy年 M月", - "dateFormatMonthDay": "M月d日" + "dateFormatMonthDay": "M月d日", + "updatesSectionTitle": "软件更新", + "currentVersionLabel": "当前版本", + "checkForUpdates": "检查更新", + "updateInProgress": "更新进行中", + "autoCheckUpdates": "自动检查更新", + "autoCheckUpdatesDescription": "每天检查新版本,安装前由你确认", + "updateSettingsFailed": "暂时无法读取更新设置,请稍后重试。", + "updateCheckFailed": "无法开始检查更新,请稍后重试,或前往发布页下载。", + "updatePreferenceFailed": "更新偏好保存失败,请重试。", + "updatesUnavailable": "当前运行方式不支持应用内更新,请使用已安装的 Floatick。", + "nativeUpdateWindowHint": "请在更新窗口中查看结果、下载或安装新版本。", + "lastUpdateCheck": "上次检查:{{date}}", + "viewReleaseNotes": "查看更新说明与下载", + "openReleaseFailed": "无法打开发布页,请检查默认浏览器设置。" } diff --git a/src/lib/api.ts b/src/lib/api.ts index 42ed88d..b89e32b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { version } from "../../package.json"; import type { TodoItem, TagWorkspace, NoteItem, AppSettings } from "@/types"; const isMock = typeof window !== "undefined" && ( @@ -134,7 +136,43 @@ const MOCK_SETTINGS: AppSettings = { collapseWhenClickingOutside: true, }; +export interface UpdateSettings { + currentVersion: string; + available: boolean; + canCheck: boolean; + automaticallyChecks: boolean; + lastCheckedAt: number | null; +} + +export const appVersion = version; + export const api = { + getUpdateSettings: async (): Promise => { + if (isMock) return { + currentVersion: version, + available: false, + canCheck: false, + automaticallyChecks: false, + lastCheckedAt: null, + }; + return invoke("get_update_settings"); + }, + checkForUpdates: async (): Promise => { + if (isMock) throw new Error("updater_unavailable"); + await invoke("check_for_updates"); + }, + setAutomaticallyChecksForUpdates: async (enabled: boolean): Promise => { + if (isMock) throw new Error("updater_unavailable"); + await invoke("set_automatically_checks_for_updates", { enabled }); + }, + openLatestRelease: async (): Promise => { + const url = "https://github.com/lucaslus/floatick/releases/latest"; + if (isMock) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + await openUrl(url); + }, // Todos getTodos: async (): Promise => { if (isMock) return MOCK_TODOS; From 8b89068e83c823e6130767e389c6a1fc1c712706 Mon Sep 17 00:00:00 2001 From: lucaslushuo <282139159+lucaslus@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:31:20 +0800 Subject: [PATCH 2/3] chore(release): prepare v0.4.2 (build 14) Align package, Cargo, and macOS bundle versions. Document updater migration and publish bilingual release notes; update the website download version and changelog. --- .github/release-notes/0.4.2.md | 23 +++++++++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 4 ++-- website/src/config/site-links.ts | 4 ++-- website/src/content/changelog.ts | 30 ++++++++++++++++++++++++++++++ 9 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 .github/release-notes/0.4.2.md diff --git a/.github/release-notes/0.4.2.md b/.github/release-notes/0.4.2.md new file mode 100644 index 0000000..52fc261 --- /dev/null +++ b/.github/release-notes/0.4.2.md @@ -0,0 +1,23 @@ +## Floatick v0.4.2 + +### 中文 + +恢复 Tauri 客户端的应用内更新,并修复更新源未签名导致的 Update Error。 + +- 新增 **设置 → 软件更新**:显示真实应用版本、手动检查更新、每日自动检查开关、最近检查时间和更新说明入口。 +- 使用 Sparkle 原生窗口展示新版本与下载进度,验签后由用户确认安装并重启;自动检查不会自动安装。 +- 打包完整的 Sparkle 框架、安装器与 XPC 服务。发布流程验证安装包配置,同时签名并校验更新源和 DMG,阻止未签名、密钥不匹配或被篡改的更新发布。 +- 保留 v0.4.1 的空正文光标与 checkbox 排版修复。 + +**升级说明:** 公开发布的 v0.4.0、v0.4.1 没有内置更新组件,需要手动安装一次 v0.4.2。已安装本地更新测试包的用户,可在本次签名更新源发布后使用“检查更新”升级。待办和便签继续保存在原有的 `~/.floatick` 中。 + +### English + +Restore in-app updates in the Tauri client and fix the Update Error caused by the unsigned feed. + +- Add **Settings → Software Updates** with the real app version, manual checks, persisted daily checks, the last check time, and a release notes link. +- Use Sparkle's native update, download progress, signature verification, and install/relaunch flow. Automatic checks do not automatically install updates. +- Bundle the complete Sparkle framework and installer services. Validate packaged settings and sign and verify both the feed and DMG before release promotion. +- Retain the empty-body caret and checklist layout fixes from v0.4.1. + +**Migration:** Public v0.4.0 and v0.4.1 builds require one manual installation of v0.4.2. Users of the local updater test build can upgrade with Check for Updates once the signed feed is live. Existing todos and notes remain in `~/.floatick`. diff --git a/README.md b/README.md index 490769e..92dddb6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Download the latest DMG installer from [GitHub Releases](https://github.com/luca Open **Settings → Software Updates** (`⌘,`) to check for a new version, read release notes, or enable daily automatic checks. Sparkle displays download progress and verifies Ed25519 signatures before installing and relaunching, with your confirmation. Automatic checks do not automatically install updates. -Versions 0.4.0 and 0.4.1 do not contain the updater; install the first updater-enabled release manually once. Updates require an internet connection; your todos and notes remain local. Update preferences are stored in macOS user defaults. +Versions 0.4.0 and 0.4.1 do not contain the updater; install v0.4.2 manually once. Updates require an internet connection; your todos and notes remain local. Update preferences are stored in macOS user defaults. ## Shortcuts & Controls diff --git a/README.zh-CN.md b/README.zh-CN.md index b007be0..e5558fc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ Floatick 是一款专为 macOS 设计的原生轻量效率工具。安静常驻 打开 **设置 → 软件更新**(`⌘,`),可手动检查新版本、查看更新说明,或开启每天自动检查。Sparkle 原生窗口负责展示下载进度、验证 Ed25519 签名,并在你确认后安装、重启;自动检查不会自动安装。 -v0.4.0 和 v0.4.1 尚未内置更新组件,需要先手动安装一次包含此功能的新版本。检查更新需要联网,待办和便签数据仍保存在本机。更新偏好保存在 macOS 用户偏好中。 +v0.4.0 和 v0.4.1 尚未内置更新组件,需要先手动安装一次 v0.4.2。检查更新需要联网,待办和便签数据仍保存在本机。更新偏好保存在 macOS 用户偏好中。 ## 快捷键与常用交互 diff --git a/package.json b/package.json index fdc0d57..8f5aea1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "floatick", - "version": "0.4.1", + "version": "0.4.2", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c18ef3..df14980 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "floatick" -version = "0.4.1" +version = "0.4.2" dependencies = [ "chrono", "dirs 6.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 123ba22..356e4bd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "floatick" -version = "0.4.1" +version = "0.4.2" description = "A focused, local-first floating todo and note app for macOS" authors = ["Lucas"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 46e8a0f..8ded7bb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Floatick", - "version": "0.4.1", + "version": "0.4.2", "identifier": "io.github.lucaslushuo.floatick", "build": { "frontendDist": "../dist", @@ -41,7 +41,7 @@ "icons/icon.ico" ], "macOS": { - "bundleVersion": "13", + "bundleVersion": "14", "frameworks": ["./Sparkle.framework"], "signingIdentity": "-", "minimumSystemVersion": "10.15" diff --git a/website/src/config/site-links.ts b/website/src/config/site-links.ts index d43f144..0873457 100644 --- a/website/src/config/site-links.ts +++ b/website/src/config/site-links.ts @@ -2,8 +2,8 @@ export const repositoryUrl = 'https://github.com/lucaslus/floatick'; // Update this alongside the published release. Keep the label and binary in sync. export const stableRelease = { - version: '0.4.0', - date: '2026-09-07', + version: '0.4.2', + date: '2026-09-08', }; export const latestDownloadUrl = diff --git a/website/src/content/changelog.ts b/website/src/content/changelog.ts index 7ff03a8..5376dd6 100644 --- a/website/src/content/changelog.ts +++ b/website/src/content/changelog.ts @@ -84,6 +84,21 @@ export const changelogCopy: Record = { export const changelogEntries: Record = { en: [ + { + version: 'v0.4.2', + date: "September 8, 2026", + dateTime: '2026-09-08', + title: "In-app updates return", + summary: "Check, download, and install signed updates directly from Floatick.", + highlights: [ + "Find the current version and manual update check in Settings → Software Updates.", + "Enable daily checks, then confirm installation and relaunch in the native update window.", + "Verify both the update feed and installer signatures before updating.", + "Users of the public v0.4.0 or v0.4.1 releases must install v0.4.2 manually once.", + ], + releaseUrl: `${repositoryUrl}/releases/tag/v0.4.2`, + compareUrl: `${repositoryUrl}/compare/v0.4.1...v0.4.2`, + }, { version: 'v0.4.0', date: 'September 7, 2026', @@ -211,6 +226,21 @@ export const changelogEntries: Record = { }, ], zh: [ + { + version: 'v0.4.2', + date: "2026 年 9 月 8 日", + dateTime: '2026-09-08', + title: "恢复应用内更新", + summary: "在 Floatick 中检查、下载并安装经过签名校验的新版本。", + highlights: [ + "在设置 → 软件更新中查看当前版本并手动检查更新。", + "支持每日自动检查,通过原生窗口确认安装并重启。", + "更新源和安装包均经过签名校验。", + "使用公开发布的 v0.4.0 或 v0.4.1 的用户,需要先手动安装一次 v0.4.2。", + ], + releaseUrl: `${repositoryUrl}/releases/tag/v0.4.2`, + compareUrl: `${repositoryUrl}/compare/v0.4.1...v0.4.2`, + }, { version: 'v0.4.0', date: '2026 年 9 月 7 日', From 80e0aaa1dbc8296dde32dcff55289ca8c1de6b2d Mon Sep 17 00:00:00 2001 From: lucaslushuo <282139159+lucaslus@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:34:27 +0800 Subject: [PATCH 3/3] fix(release): keep curated notes scoped to the current version Use the version-specific release notes and an exact tag comparison when available. Avoid repeating the previous release PR in GitHub notes and the signed Sparkle feed when stable tags point to tested candidate commits. --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f843510..d208a4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -203,8 +203,16 @@ jobs: if [[ -f ".github/release-notes/$APP_VERSION.md" ]]; then cat ".github/release-notes/$APP_VERSION.md" printf '\n\n' + # Release tags identify the tested candidate before the PR merge. + # GitHub's auto notes can therefore repeat the previous release PR. + previous_tag=$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${RELEASE_COMMIT}^" 2>/dev/null || true) + if [[ -n "$previous_tag" ]]; then + printf '**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \ + "$GITHUB_REPOSITORY" "$previous_tag" "$GITHUB_REF_NAME" + fi + else + cat "$generated_notes" fi - cat "$generated_notes" } > "$final_notes" dmg_name="$PRODUCT_NAME-$APP_VERSION-macos-universal.dmg"