Skip to content

Commit 190a637

Browse files
committed
Updater: preserve macOS FDA perms across updates
- New `updater` module (macOS-only): check, download, verify, and install updates by syncing files into the existing `.app` bundle instead of replacing it - Minisign signature verification matching Tauri's internal format - Privilege escalation via `osascript` when direct writes are denied - Frontend branches on platform: `invoke()` on macOS, Tauri updater plugin on other platforms - Tauri updater plugin registration gated to non-macOS with `#[cfg]`
1 parent 43c3bed commit 190a637

11 files changed

Lines changed: 927 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ block2 = "0.6"
137137
security-framework = "3.2"
138138
# Drive indexing: macOS FSEvents watcher with event IDs and sinceWhen replay
139139
cmdr-fsevent-stream = { path = "../../../crates/fsevent-stream" }
140+
# Custom updater: signature verification, tarball extraction, version comparison
141+
minisign-verify = "0.2"
142+
flate2 = "1"
143+
tar = "0.4"
144+
semver = "1"
140145

141146
# CrabNebula automation plugin for macOS E2E testing (optional, feature-gated)
142147
tauri-plugin-automation = { version = "0.1", optional = true }

apps/desktop/src-tauri/src/lib.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ use cocoon as _;
4848
#[cfg(not(debug_assertions))]
4949
use tauri_plugin_mcp_bridge as _;
5050
//noinspection ALL
51+
// tauri_plugin_updater is only registered on non-macOS (custom updater handles macOS)
52+
#[cfg(target_os = "macos")]
53+
use tauri_plugin_updater as _;
54+
//noinspection ALL
5155
// security_framework is used in network/keychain.rs for Keychain integration
5256
#[cfg(target_os = "macos")]
5357
use security_framework as _;
@@ -100,6 +104,8 @@ mod permissions;
100104
mod permissions_linux;
101105
mod settings;
102106
#[cfg(target_os = "macos")]
107+
mod updater;
108+
#[cfg(target_os = "macos")]
103109
mod volumes;
104110
#[cfg(target_os = "linux")]
105111
mod volumes_linux;
@@ -172,7 +178,9 @@ pub fn run() {
172178
#[cfg(feature = "automation")]
173179
let builder = builder.plugin(tauri_plugin_automation::init());
174180

175-
// Skip updater plugin in CI to avoid network dependency and latency during E2E tests
181+
// Skip Tauri updater plugin on macOS (custom updater preserves TCC permissions)
182+
// and in CI (avoids network dependency and latency during E2E tests)
183+
#[cfg(not(target_os = "macos"))]
176184
let builder = if std::env::var("CI").is_ok() {
177185
builder
178186
} else {
@@ -353,6 +361,10 @@ pub fn run() {
353361
let _ = window.set_title_bar_style(TitleBarStyle::Visible);
354362
}
355363

364+
// Initialize custom updater state (shared between download and install commands)
365+
#[cfg(target_os = "macos")]
366+
app.manage(updater::UpdateState::new());
367+
356368
// Initialize pane state store for MCP context tools
357369
app.manage(mcp::PaneStateStore::new());
358370

@@ -866,6 +878,13 @@ pub fn run() {
866878
commands::clipboard::read_clipboard_files,
867879
commands::clipboard::read_clipboard_text,
868880
commands::clipboard::clear_clipboard_cut_state,
881+
// Custom updater commands (macOS rsync-into-bundle, preserves TCC permissions)
882+
#[cfg(target_os = "macos")]
883+
updater::check_for_update,
884+
#[cfg(target_os = "macos")]
885+
updater::download_update,
886+
#[cfg(target_os = "macos")]
887+
updater::install_update,
869888
])
870889
.on_window_event(|window, event| {
871890
// When the main window is closed, quit the entire app (including settings/debug/viewer windows)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Updater module
2+
3+
Custom macOS updater that syncs files *into* the existing `.app` bundle, preserving its inode and
4+
`com.apple.macl` xattr so macOS TCC (Full Disk Access) permissions survive across updates.
5+
6+
Compiled only on macOS (`#[cfg(target_os = "macos")]`). On other platforms, the Tauri updater plugin handles updates
7+
and the frontend calls the plugin API directly.
8+
9+
## File map
10+
11+
| File | Purpose |
12+
|------|---------|
13+
| `mod.rs` | Three Tauri commands (`check_for_update`, `download_update`, `install_update`) and shared `UpdateState` |
14+
| `manifest.rs` | Parses `latest.json`, compares versions, resolves platform key |
15+
| `signature.rs` | Minisign signature verification (base64-wrapped, matching Tauri's format) |
16+
| `installer.rs` | Extracts tarball, syncs into running bundle, handles privilege escalation |
17+
18+
## Key decisions
19+
20+
**Decision**: Sync files into the bundle instead of replacing the `.app` directory.
21+
**Why**: Replacing the bundle changes its inode, which causes macOS TCC to lose FDA grants. Users would have to
22+
re-grant Full Disk Access after every update.
23+
24+
**Decision**: Sync order is Resources, Info.plist, _CodeSignature, then MacOS binary last.
25+
**Why**: Updating the binary last minimizes the window where the code signature is inconsistent with the binary on
26+
disk. If the app crashes mid-update, the old binary is still intact.
27+
28+
**Decision**: Unconditional deletion of stale files after sync.
29+
**Why**: Old files left behind could cause version mismatches or bloat. The deletion pass removes anything in the
30+
destination that isn't in the source, then cleans up empty directories bottom-up.
31+
32+
**Decision**: Minisign verification before writing tarball to disk.
33+
**Why**: Ensures integrity and authenticity of the update. The public key is compiled into the binary. Both key and
34+
signatures use base64(minisign-text-format) encoding, matching Tauri's internal convention.
35+
36+
**Decision**: Privilege escalation via `osascript` with `rsync -a --delete`.
37+
**Why**: When the app is installed in `/Applications` (owned by root), direct writes fail. `osascript`'s
38+
`do shell script ... with administrator privileges` shows the native macOS auth dialog. `rsync` is used because it
39+
expresses the full sync (copy + delete stale) in a single shell command.
40+
41+
## Key patterns and gotchas
42+
43+
- **macOS-only.** The module, command registrations, and `UpdateState` are all gated with `#[cfg(target_os = "macos")]`.
44+
On non-macOS, the frontend uses `@tauri-apps/plugin-updater` directly.
45+
- **Staging dir is `/tmp/cmdr-update-staging`.** Cleaned before and after install. If the app crashes mid-install,
46+
leftover staging doesn't block the next attempt (it gets cleaned on retry).
47+
- **Privilege escalation via `osascript`.** Only triggers when direct writes to `/Applications/Cmdr.app` are denied.
48+
Users who run from `~/Applications` or a dev build won't see the auth dialog.
49+
- **CI guard.** `check_for_update` returns `None` when the `CI` env var is set, avoiding network calls in tests.
50+
- **Manifest URL is hardcoded** (`https://getcmdr.com/latest.json`), not configurable from the frontend.
51+
52+
## Dependencies
53+
54+
- `reqwest` -- HTTP client for manifest and tarball download
55+
- `minisign-verify` -- signature verification
56+
- `flate2`, `tar` -- tarball extraction
57+
- `filetime` -- touching the bundle after install to trigger LaunchServices refresh
58+
- `base64` -- decoding the double-encoded minisign key and signatures

0 commit comments

Comments
 (0)