ruby_everywhere 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,745 @@
1
+ // Self-updater: our own take on the Tauri updater, built for hand-assembled
2
+ // bundles (tauri.conf has bundle.active=false, so tauri-plugin-updater's
3
+ // artifacts don't exist here).
4
+ //
5
+ // Contract (see cli/lib/everywhere/update_manifest.rb — the canonical spec):
6
+ // GET {updates.url}/{channel}/{os}/{arch}/latest.json?current={version}
7
+ // answered identically by a raw bucket/CDN or a Platform endpoint. An update
8
+ // installs only after the FULL verification chain passes:
9
+ // sha256 -> minisign signature (public key baked into everywhere.json) ->
10
+ // codesign --verify --deep --strict -> Info.plist bundle_id + version match.
11
+ //
12
+ // Page JS drives it through bridge events (everywhere:update-check /
13
+ // update-install / update-set-channel in; update-available / -none /
14
+ // -progress / -ready / -error / -channel out); the "Check for Updates…" menu
15
+ // item drives the same flows with native dialogs. `updates.auto` picks the
16
+ // background behaviour: off | check | download | install (silent swap staged
17
+ // in RAM-free renames on quit).
18
+ //
19
+ // Channel: everywhere.yml's updates.channel is the default; a user choice made
20
+ // through the bridge (Everywhere.updates.setChannel) is persisted to the app
21
+ // data dir and overrides it from then on — the server-driven settings page
22
+ // decides who gets to see a "beta" option, the shell just honors the pick.
23
+
24
+ use std::path::{Path, PathBuf};
25
+ use std::process::Command;
26
+ use std::sync::atomic::{AtomicBool, Ordering};
27
+ use std::sync::Mutex;
28
+ use std::time::Duration;
29
+
30
+ use tauri::{Emitter, Listener, Manager};
31
+
32
+ #[derive(Clone, Copy, PartialEq, PartialOrd)]
33
+ pub enum Auto {
34
+ Off,
35
+ Check,
36
+ Download,
37
+ Install,
38
+ }
39
+
40
+ pub struct UpdateConfig {
41
+ pub url: String,
42
+ pub channel: String,
43
+ pub public_key: String,
44
+ pub auto: Auto,
45
+ pub interval: u64,
46
+ pub current: semver::Version,
47
+ pub bundle_id: String,
48
+ }
49
+
50
+ #[derive(Clone)]
51
+ pub struct Manifest {
52
+ pub version: String,
53
+ pub semver: semver::Version,
54
+ pub url: String,
55
+ pub sha256: String,
56
+ pub size_bytes: u64,
57
+ pub signature: String,
58
+ pub notes: Option<String>,
59
+ pub notes_html: Option<String>,
60
+ pub published_at: Option<String>,
61
+ }
62
+
63
+ struct Pending {
64
+ manifest: Manifest,
65
+ extracted_app: PathBuf,
66
+ }
67
+
68
+ pub struct UpdaterState {
69
+ cfg: UpdateConfig,
70
+ // The live channel. Starts as cfg.channel (which already reflects a
71
+ // persisted override — see apply_channel_override) and moves when the page
72
+ // calls setChannel.
73
+ channel: Mutex<String>,
74
+ pending: Mutex<Option<Pending>>,
75
+ busy: AtomicBool,
76
+ }
77
+
78
+ impl UpdaterState {
79
+ fn channel(&self) -> String {
80
+ self.channel.lock().unwrap().clone()
81
+ }
82
+ }
83
+
84
+ impl UpdateConfig {
85
+ // The shell subset of everywhere.yml's updates: section, from the same
86
+ // raw config JSON the rest of the shell reads. None => updater disabled.
87
+ pub fn parse(raw_json: &str) -> Option<UpdateConfig> {
88
+ let value: serde_json::Value = serde_json::from_str(raw_json).ok()?;
89
+ let updates = value.get("updates")?.as_object()?;
90
+ let url = updates.get("url")?.as_str()?.trim_end_matches('/').to_string();
91
+ let public_key = updates.get("public_key")?.as_str()?.to_string();
92
+
93
+ if !(url.starts_with("https://")
94
+ || url.starts_with("http://127.0.0.1")
95
+ || url.starts_with("http://localhost"))
96
+ {
97
+ eprintln!("[updater] refusing non-https updates.url {url}; updater disabled");
98
+ return None;
99
+ }
100
+ let Some(current) = value["version"].as_str().and_then(lenient_semver) else {
101
+ eprintln!("[updater] no parseable app version in config; updater disabled");
102
+ return None;
103
+ };
104
+
105
+ Some(UpdateConfig {
106
+ url,
107
+ channel: updates.get("channel").and_then(|v| v.as_str()).unwrap_or("stable").to_string(),
108
+ public_key,
109
+ auto: match updates.get("auto").and_then(|v| v.as_str()).unwrap_or("check") {
110
+ "off" => Auto::Off,
111
+ "download" => Auto::Download,
112
+ "install" => Auto::Install,
113
+ _ => Auto::Check,
114
+ },
115
+ interval: updates.get("interval").and_then(|v| v.as_u64()).unwrap_or(21_600).max(300),
116
+ current,
117
+ bundle_id: value["bundle_id"].as_str().unwrap_or("com.rubyeverywhere.app").to_string(),
118
+ })
119
+ }
120
+ }
121
+
122
+ pub fn enabled(raw_json: &str) -> bool {
123
+ UpdateConfig::parse(raw_json).is_some()
124
+ }
125
+
126
+ // Fold the persisted user channel choice (if any) into the raw config value.
127
+ // main.rs calls this before the config is parsed OR injected into pages, so
128
+ // the updater, the menu, and __EVERYWHERE_CONFIG__.updates.channel all agree
129
+ // on the effective channel.
130
+ pub fn apply_channel_override(handle: &tauri::AppHandle, value: &mut serde_json::Value) {
131
+ if !value["updates"].is_object() {
132
+ return;
133
+ }
134
+ let bundle_id = value["bundle_id"].as_str().unwrap_or("com.rubyeverywhere.app").to_string();
135
+ let Some(path) = channel_file(handle, &bundle_id) else { return };
136
+ let Ok(saved) = std::fs::read_to_string(&path) else { return };
137
+ let saved = saved.trim();
138
+ if valid_channel(saved) {
139
+ value["updates"]["channel"] = serde_json::json!(saved);
140
+ }
141
+ }
142
+
143
+ pub fn init(app: &tauri::App, raw_json: &str) {
144
+ let Some(cfg) = UpdateConfig::parse(raw_json) else {
145
+ return;
146
+ };
147
+ let auto = cfg.auto;
148
+ let interval = cfg.interval;
149
+ let channel = Mutex::new(cfg.channel.clone());
150
+ app.manage(UpdaterState { cfg, channel, pending: Mutex::new(None), busy: AtomicBool::new(false) });
151
+
152
+ {
153
+ let handle = app.handle().clone();
154
+ std::thread::spawn(move || cleanup_stale(&handle));
155
+ }
156
+
157
+ // Bridge: pages emit events (same sanctioned webview->shell path as
158
+ // everywhere:notify).
159
+ {
160
+ let handle = app.handle().clone();
161
+ app.listen_any("everywhere:update-check", move |_| {
162
+ let handle = handle.clone();
163
+ std::thread::spawn(move || explicit_check(&handle));
164
+ });
165
+ }
166
+ {
167
+ let handle = app.handle().clone();
168
+ app.listen_any("everywhere:update-install", move |_| {
169
+ let handle = handle.clone();
170
+ std::thread::spawn(move || {
171
+ if let Err(e) = install_flow(&handle) {
172
+ emit_error(&handle, &e);
173
+ }
174
+ });
175
+ });
176
+ }
177
+ {
178
+ let handle = app.handle().clone();
179
+ app.listen_any("everywhere:update-set-channel", move |event| {
180
+ let payload = event.payload().to_string();
181
+ let handle = handle.clone();
182
+ std::thread::spawn(move || set_channel(&handle, &payload));
183
+ });
184
+ }
185
+
186
+ if auto != Auto::Off {
187
+ let handle = app.handle().clone();
188
+ std::thread::spawn(move || {
189
+ // Let the app boot before the first check.
190
+ std::thread::sleep(Duration::from_secs(15));
191
+ loop {
192
+ background_pass(&handle, auto);
193
+ std::thread::sleep(Duration::from_secs(interval));
194
+ }
195
+ });
196
+ }
197
+ }
198
+
199
+ // ---- flows ------------------------------------------------------------------
200
+
201
+ // Bridge-triggered channel switch (Everywhere.updates.setChannel). Validates,
202
+ // persists the choice so it survives relaunches, retargets the running
203
+ // updater, and always answers: update-channel on success, update-error on a
204
+ // bad request. A staged download from the old channel is discarded — it no
205
+ // longer represents what the user asked to run.
206
+ fn set_channel(handle: &tauri::AppHandle, payload: &str) {
207
+ let Some(state) = state(handle) else { return };
208
+ let requested = serde_json::from_str::<serde_json::Value>(payload)
209
+ .ok()
210
+ .and_then(|v| v["channel"].as_str().map(str::to_string));
211
+ let Some(channel) = requested.filter(|c| valid_channel(c)) else {
212
+ emit_error(handle, "invalid update channel (letters, digits, . _ - only)");
213
+ return;
214
+ };
215
+
216
+ if let Some(path) = channel_file(handle, &state.cfg.bundle_id) {
217
+ let write = path
218
+ .parent()
219
+ .map(|dir| std::fs::create_dir_all(dir).map_err(|e| e.to_string()))
220
+ .unwrap_or(Ok(()))
221
+ .and_then(|()| std::fs::write(&path, &channel).map_err(|e| e.to_string()));
222
+ if let Err(e) = write {
223
+ emit_error(handle, &format!("could not save channel choice: {e}"));
224
+ return;
225
+ }
226
+ }
227
+
228
+ let changed = {
229
+ let mut current = state.channel.lock().unwrap();
230
+ let changed = *current != channel;
231
+ *current = channel.clone();
232
+ changed
233
+ };
234
+ if changed {
235
+ *state.pending.lock().unwrap() = None;
236
+ }
237
+ println!("[updater] channel set to {channel}");
238
+ let _ = handle.emit_to("main", "everywhere:update-channel", serde_json::json!({ "channel": channel }));
239
+ }
240
+
241
+ // Bridge-triggered check: always answers with an event (available/none/error).
242
+ fn explicit_check(handle: &tauri::AppHandle) {
243
+ let Some(state) = state(handle) else { return };
244
+ if state.busy.swap(true, Ordering::SeqCst) {
245
+ return;
246
+ }
247
+ match check(&state.cfg, &state.channel()) {
248
+ Ok(Some(m)) => emit_available(handle, &m),
249
+ Ok(None) => {
250
+ let _ = handle.emit_to("main", "everywhere:update-none",
251
+ serde_json::json!({ "version": state.cfg.current.to_string() }));
252
+ }
253
+ Err(e) => emit_error(handle, &e),
254
+ }
255
+ state.busy.store(false, Ordering::SeqCst);
256
+ }
257
+
258
+ // Menu-triggered check: native dialogs, then the shared install flow.
259
+ pub fn interactive_check(handle: &tauri::AppHandle) {
260
+ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
261
+
262
+ let Some(state) = state(handle) else { return };
263
+ if state.busy.swap(true, Ordering::SeqCst) {
264
+ return;
265
+ }
266
+ let outcome = check(&state.cfg, &state.channel());
267
+ state.busy.store(false, Ordering::SeqCst);
268
+
269
+ match outcome {
270
+ Ok(Some(m)) => {
271
+ emit_available(handle, &m);
272
+ let notes = m.notes.clone().map(|n| format!("\n\n{n}")).unwrap_or_default();
273
+ let install = handle
274
+ .dialog()
275
+ .message(format!("Version {} is available (you have {}).{notes}", m.version, state.cfg.current))
276
+ .title("Update Available")
277
+ .buttons(MessageDialogButtons::OkCancelCustom(
278
+ "Install and Restart".to_string(),
279
+ "Later".to_string(),
280
+ ))
281
+ .blocking_show();
282
+ if install {
283
+ if let Err(e) = install_flow(handle) {
284
+ emit_error(handle, &e);
285
+ handle.dialog().message(e).title("Update Failed").kind(MessageDialogKind::Error).blocking_show();
286
+ }
287
+ }
288
+ }
289
+ Ok(None) => {
290
+ handle
291
+ .dialog()
292
+ .message(format!("You're up to date ({}).", state.cfg.current))
293
+ .title("No Updates")
294
+ .blocking_show();
295
+ }
296
+ Err(e) => {
297
+ emit_error(handle, &e);
298
+ handle.dialog().message(e).title("Update Check Failed").kind(MessageDialogKind::Error).blocking_show();
299
+ }
300
+ }
301
+ }
302
+
303
+ // The `updates.auto` background loop body.
304
+ fn background_pass(handle: &tauri::AppHandle, auto: Auto) {
305
+ let Some(state) = state(handle) else { return };
306
+ if state.busy.swap(true, Ordering::SeqCst) {
307
+ return;
308
+ }
309
+ let result = (|| -> Result<(), String> {
310
+ let Some(m) = check(&state.cfg, &state.channel())? else { return Ok(()) };
311
+ emit_available(handle, &m);
312
+ if auto >= Auto::Download && !is_staged(&state, &m) {
313
+ let staged = download_and_stage(handle, &state.cfg, &m)?;
314
+ *state.pending.lock().unwrap() = Some(Pending { manifest: m.clone(), extracted_app: staged });
315
+ let _ = handle.emit_to("main", "everywhere:update-ready", serde_json::json!({ "version": m.version }));
316
+ }
317
+ Ok(())
318
+ // auto == Install finishes the job in install_pending_on_exit: the
319
+ // verified bundle swaps in via two renames when the user quits.
320
+ })();
321
+ state.busy.store(false, Ordering::SeqCst);
322
+ if let Err(e) = result {
323
+ eprintln!("[updater] background pass failed: {e}");
324
+ emit_error(handle, &e);
325
+ }
326
+ }
327
+
328
+ // Explicit install (bridge update-install event, or the menu dialog): stage if
329
+ // needed, swap, relaunch.
330
+ fn install_flow(handle: &tauri::AppHandle) -> Result<(), String> {
331
+ let state = state(handle).ok_or("updater not configured")?;
332
+
333
+ let pending = state.pending.lock().unwrap().take();
334
+ let pending = match pending {
335
+ Some(p) => p,
336
+ None => {
337
+ let m = check(&state.cfg, &state.channel())?.ok_or("already up to date")?;
338
+ emit_available(handle, &m);
339
+ let staged = download_and_stage(handle, &state.cfg, &m)?;
340
+ Pending { manifest: m, extracted_app: staged }
341
+ }
342
+ };
343
+
344
+ let _ = handle.emit_to("main", "everywhere:update-ready",
345
+ serde_json::json!({ "version": pending.manifest.version }));
346
+ swap_bundle(&pending.extracted_app, true)?;
347
+ handle.exit(0);
348
+ Ok(())
349
+ }
350
+
351
+ // RunEvent::Exit hook (main.rs): a fully staged+verified silent update swaps
352
+ // in on quit. Renames only — adds no perceptible latency to quit.
353
+ pub fn install_pending_on_exit(app: &tauri::AppHandle) {
354
+ let Some(state) = app.try_state::<UpdaterState>() else { return };
355
+ if state.cfg.auto != Auto::Install {
356
+ return;
357
+ }
358
+ let pending = state.pending.lock().unwrap().take();
359
+ if let Some(pending) = pending {
360
+ match swap_bundle(&pending.extracted_app, false) {
361
+ Ok(()) => println!("[updater] installed {} on quit", pending.manifest.version),
362
+ Err(e) => eprintln!("[updater] install-on-quit failed: {e}"),
363
+ }
364
+ }
365
+ }
366
+
367
+ // ---- check ------------------------------------------------------------------
368
+
369
+ fn check(cfg: &UpdateConfig, channel: &str) -> Result<Option<Manifest>, String> {
370
+ let url = format!(
371
+ "{}/{}/{}/{}/latest.json?current={}",
372
+ cfg.url, channel, os_name(), arch_name(), cfg.current
373
+ );
374
+ println!("[updater] checking {url}");
375
+
376
+ let resp = ureq::get(&url)
377
+ .set("User-Agent", &format!("RubyEverywhere/{} ({})", cfg.current, os_name()))
378
+ .timeout(Duration::from_secs(30))
379
+ .call()
380
+ .map_err(|e| format!("update check failed: {e}"))?;
381
+ let value: serde_json::Value = resp.into_json().map_err(|e| format!("bad manifest JSON: {e}"))?;
382
+
383
+ let manifest = parse_manifest(&value)?;
384
+ Ok((manifest.semver > cfg.current).then_some(manifest))
385
+ }
386
+
387
+ fn parse_manifest(v: &serde_json::Value) -> Result<Manifest, String> {
388
+ let field = |k: &str| v[k].as_str().map(str::to_string).ok_or(format!("manifest missing {k}"));
389
+ let version = field("version")?;
390
+ Ok(Manifest {
391
+ semver: lenient_semver(&version).ok_or(format!("unparseable manifest version {version}"))?,
392
+ version,
393
+ url: field("url")?,
394
+ sha256: field("sha256")?.to_lowercase(),
395
+ size_bytes: v["size_bytes"].as_u64().unwrap_or(0),
396
+ signature: field("signature")?,
397
+ notes: v["notes"].as_str().map(str::to_string),
398
+ notes_html: v["notes_html"].as_str().map(str::to_string),
399
+ published_at: v["published_at"].as_str().map(str::to_string),
400
+ })
401
+ }
402
+
403
+ // ---- download + verify ------------------------------------------------------
404
+
405
+ // Fetch, hash-check, signature-check, extract, codesign-check. Returns the
406
+ // path of the verified extracted .app, ready to swap.
407
+ fn download_and_stage(
408
+ handle: &tauri::AppHandle,
409
+ cfg: &UpdateConfig,
410
+ m: &Manifest,
411
+ ) -> Result<PathBuf, String> {
412
+ if !(m.url.starts_with("https://")
413
+ || m.url.starts_with("http://127.0.0.1")
414
+ || m.url.starts_with("http://localhost"))
415
+ {
416
+ return Err(format!("refusing non-https artifact url {}", m.url));
417
+ }
418
+
419
+ let dir = updates_dir(handle, cfg).join(&m.version);
420
+ std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {dir:?}: {e}"))?;
421
+ let filename = m.url.rsplit('/').next().unwrap_or("update.zip");
422
+ let zip = dir.join(filename);
423
+
424
+ if !(zip.exists() && sha256_file(&zip)? == m.sha256) {
425
+ download(handle, &m.url, &zip, m.size_bytes)?;
426
+ let actual = sha256_file(&zip)?;
427
+ if actual != m.sha256 {
428
+ let _ = std::fs::remove_file(&zip);
429
+ return Err(format!("sha256 mismatch: manifest {} vs downloaded {actual}", m.sha256));
430
+ }
431
+ }
432
+
433
+ verify_signature(&zip, &m.signature, &cfg.public_key)?;
434
+
435
+ let extracted = dir.join("extracted");
436
+ let _ = std::fs::remove_dir_all(&extracted);
437
+ std::fs::create_dir_all(&extracted).map_err(|e| e.to_string())?;
438
+ run_ok(Command::new("/usr/bin/ditto").arg("-xk").arg(&zip).arg(&extracted), "ditto extract")?;
439
+
440
+ let app_bundle = find_app_bundle(&extracted)?;
441
+ verify_bundle(&app_bundle, cfg, m)?;
442
+ println!("[updater] {} staged and verified at {app_bundle:?}", m.version);
443
+ Ok(app_bundle)
444
+ }
445
+
446
+ fn download(handle: &tauri::AppHandle, url: &str, dest: &Path, total: u64) -> Result<(), String> {
447
+ use std::io::{Read, Write};
448
+
449
+ let resp = ureq::get(url).timeout(Duration::from_secs(600)).call()
450
+ .map_err(|e| format!("download failed: {e}"))?;
451
+ let mut reader = resp.into_reader();
452
+ let tmp = dest.with_extension("part");
453
+ let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
454
+
455
+ let mut buf = vec![0u8; 1 << 16];
456
+ let mut downloaded: u64 = 0;
457
+ let mut last_emit: u64 = 0;
458
+ loop {
459
+ let n = reader.read(&mut buf).map_err(|e| format!("download interrupted: {e}"))?;
460
+ if n == 0 {
461
+ break;
462
+ }
463
+ file.write_all(&buf[..n]).map_err(|e| e.to_string())?;
464
+ downloaded += n as u64;
465
+ if downloaded - last_emit > 1_000_000 {
466
+ last_emit = downloaded;
467
+ let _ = handle.emit_to("main", "everywhere:update-progress",
468
+ serde_json::json!({ "downloaded": downloaded, "total": total }));
469
+ }
470
+ }
471
+ file.flush().map_err(|e| e.to_string())?;
472
+ drop(file);
473
+ std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
474
+ let _ = handle.emit_to("main", "everywhere:update-progress",
475
+ serde_json::json!({ "downloaded": downloaded, "total": total }));
476
+ Ok(())
477
+ }
478
+
479
+ fn verify_signature(path: &Path, signature: &str, public_key: &str) -> Result<(), String> {
480
+ let pk = minisign_verify::PublicKey::from_base64(public_key)
481
+ .map_err(|e| format!("bad updates.public_key: {e}"))?;
482
+ let sig = minisign_verify::Signature::decode(signature)
483
+ .map_err(|e| format!("bad manifest signature: {e}"))?;
484
+ let data = std::fs::read(path).map_err(|e| e.to_string())?;
485
+ pk.verify(&data, &sig, false)
486
+ .map_err(|_| "signature verification FAILED — artifact does not match the app's signing key".to_string())
487
+ }
488
+
489
+ fn find_app_bundle(dir: &Path) -> Result<PathBuf, String> {
490
+ std::fs::read_dir(dir)
491
+ .map_err(|e| e.to_string())?
492
+ .filter_map(|e| e.ok())
493
+ .map(|e| e.path())
494
+ .find(|p| p.extension().is_some_and(|e| e == "app"))
495
+ .ok_or("no .app bundle in update archive".to_string())
496
+ }
497
+
498
+ // The post-extract checks: Apple's signature, then identity — the update must
499
+ // BE this app (bundle id) at the version the manifest claims.
500
+ fn verify_bundle(bundle: &Path, cfg: &UpdateConfig, m: &Manifest) -> Result<(), String> {
501
+ run_ok(
502
+ Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle),
503
+ "codesign verification",
504
+ )?;
505
+
506
+ let plist = bundle.join("Contents/Info.plist");
507
+ let bundle_id = plist_value(&plist, "CFBundleIdentifier")?;
508
+ if bundle_id != cfg.bundle_id {
509
+ return Err(format!("update bundle is {bundle_id}, not {} — refusing", cfg.bundle_id));
510
+ }
511
+ let version = plist_value(&plist, "CFBundleShortVersionString")?;
512
+ if version != m.version {
513
+ return Err(format!("update bundle is version {version}, manifest says {} — refusing", m.version));
514
+ }
515
+
516
+ // Quarantine would make Gatekeeper re-prompt on a file we already verified.
517
+ let _ = Command::new("/usr/bin/xattr").args(["-dr", "com.apple.quarantine"]).arg(bundle).status();
518
+ Ok(())
519
+ }
520
+
521
+ fn plist_value(plist: &Path, key: &str) -> Result<String, String> {
522
+ let out = Command::new("/usr/libexec/PlistBuddy")
523
+ .arg("-c")
524
+ .arg(format!("Print :{key}"))
525
+ .arg(plist)
526
+ .output()
527
+ .map_err(|e| e.to_string())?;
528
+ if !out.status.success() {
529
+ return Err(format!("no {key} in {plist:?}"));
530
+ }
531
+ Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
532
+ }
533
+
534
+ // ---- swap + relaunch --------------------------------------------------------
535
+
536
+ // Sparkle-style swap of a RUNNING app: stage the new bundle onto the
537
+ // destination volume, then two renames — the running process keeps its open
538
+ // files; the paths just change owners. A detached helper deletes the old
539
+ // bundle (and relaunches) once this process is gone.
540
+ fn swap_bundle(new_app: &Path, relaunch: bool) -> Result<(), String> {
541
+ let dest = current_app_bundle()?;
542
+ let parent = dest.parent().ok_or("app bundle has no parent dir")?;
543
+ let name = dest.file_name().unwrap_or_default().to_string_lossy().to_string();
544
+
545
+ if !is_writable(parent) {
546
+ return Err(format!(
547
+ "{} isn't writable — move the app to Applications or your home folder, or reinstall manually",
548
+ parent.display()
549
+ ));
550
+ }
551
+
552
+ let staged = parent.join(format!(".{name}.update"));
553
+ let old = parent.join(format!(".{name}.old-{}", std::process::id()));
554
+ let _ = std::fs::remove_dir_all(&staged);
555
+ // ditto (not rename): app_data may be another volume, and renames are only
556
+ // atomic same-volume.
557
+ run_ok(Command::new("/usr/bin/ditto").arg(new_app).arg(&staged), "staging copy")?;
558
+
559
+ std::fs::rename(&dest, &old).map_err(|e| format!("could not move old bundle aside: {e}"))?;
560
+ if let Err(e) = std::fs::rename(&staged, &dest) {
561
+ let _ = std::fs::rename(&old, &dest); // roll back, leave the app intact
562
+ return Err(format!("could not move update into place: {e}"));
563
+ }
564
+
565
+ let _ = Command::new("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister")
566
+ .arg("-f")
567
+ .arg(&dest)
568
+ .status();
569
+
570
+ // Helper outlives us (own process group): wait for exit, drop the old
571
+ // bundle, optionally relaunch.
572
+ let open_cmd = if relaunch { format!("open {}; ", shq(&dest)) } else { String::new() };
573
+ let script = format!(
574
+ "while /bin/kill -0 {pid} 2>/dev/null; do sleep 0.2; done; rm -rf {old}; {open_cmd}exit 0",
575
+ pid = std::process::id(),
576
+ old = shq(&old),
577
+ );
578
+ let mut helper = Command::new("/bin/sh");
579
+ helper.arg("-c").arg(script);
580
+ #[cfg(unix)]
581
+ {
582
+ use std::os::unix::process::CommandExt;
583
+ helper.process_group(0);
584
+ }
585
+ helper.spawn().map_err(|e| format!("could not spawn relaunch helper: {e}"))?;
586
+ Ok(())
587
+ }
588
+
589
+ // current_exe -> <App>.app/Contents/MacOS/<exe> -> <App>.app
590
+ fn current_app_bundle() -> Result<PathBuf, String> {
591
+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;
592
+ let bundle = exe.parent().and_then(Path::parent).and_then(Path::parent)
593
+ .ok_or("not running from an app bundle")?;
594
+ if bundle.extension().is_some_and(|e| e == "app") {
595
+ Ok(bundle.to_path_buf())
596
+ } else {
597
+ Err("not running from an installed .app (dev build?) — nothing to swap".to_string())
598
+ }
599
+ }
600
+
601
+ fn is_writable(dir: &Path) -> bool {
602
+ let probe = dir.join(format!(".everywhere-w-{}", std::process::id()));
603
+ match std::fs::File::create(&probe) {
604
+ Ok(_) => {
605
+ let _ = std::fs::remove_file(&probe);
606
+ true
607
+ }
608
+ Err(_) => false,
609
+ }
610
+ }
611
+
612
+ // ---- housekeeping -----------------------------------------------------------
613
+
614
+ // Next-boot pruning: version dirs at or below what we now run, orphaned
615
+ // .old-* bundles from a previous swap whose helper died early.
616
+ fn cleanup_stale(handle: &tauri::AppHandle) {
617
+ let Some(state) = state(handle) else { return };
618
+ let dir = updates_dir(handle, &state.cfg);
619
+ if let Ok(entries) = std::fs::read_dir(&dir) {
620
+ for entry in entries.filter_map(|e| e.ok()) {
621
+ let name = entry.file_name().to_string_lossy().to_string();
622
+ if lenient_semver(&name).is_some_and(|v| v <= state.cfg.current) {
623
+ let _ = std::fs::remove_dir_all(entry.path());
624
+ }
625
+ }
626
+ }
627
+ if let Ok(bundle) = current_app_bundle() {
628
+ if let Some(parent) = bundle.parent() {
629
+ if let Ok(entries) = std::fs::read_dir(parent) {
630
+ for entry in entries.filter_map(|e| e.ok()) {
631
+ let name = entry.file_name().to_string_lossy().to_string();
632
+ if name.starts_with('.') && name.contains(".app.old-") {
633
+ let _ = std::fs::remove_dir_all(entry.path());
634
+ }
635
+ }
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ fn is_staged(state: &UpdaterState, m: &Manifest) -> bool {
642
+ state
643
+ .pending
644
+ .lock()
645
+ .unwrap()
646
+ .as_ref()
647
+ .is_some_and(|p| p.manifest.version == m.version && p.extracted_app.exists())
648
+ }
649
+
650
+ fn updates_dir(handle: &tauri::AppHandle, cfg: &UpdateConfig) -> PathBuf {
651
+ handle
652
+ .path()
653
+ .data_dir()
654
+ .map(|d| d.join(&cfg.bundle_id))
655
+ .unwrap_or_else(|_| std::env::temp_dir())
656
+ .join("updates")
657
+ }
658
+
659
+ // ---- helpers ----------------------------------------------------------------
660
+
661
+ fn state(handle: &tauri::AppHandle) -> Option<tauri::State<'_, UpdaterState>> {
662
+ handle.try_state::<UpdaterState>()
663
+ }
664
+
665
+ // Where the user's channel choice lives: <data dir>/<bundle_id>/update-channel,
666
+ // a plain-text sibling of the updates/ staging dir.
667
+ fn channel_file(handle: &tauri::AppHandle, bundle_id: &str) -> Option<PathBuf> {
668
+ handle.path().data_dir().ok().map(|d| d.join(bundle_id).join("update-channel"))
669
+ }
670
+
671
+ // Channel names become a path segment of the feed URL — keep them boring.
672
+ fn valid_channel(s: &str) -> bool {
673
+ !s.is_empty()
674
+ && s.len() <= 64
675
+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
676
+ }
677
+
678
+ // notes = markdown source (also what the native dialog shows); notes_html =
679
+ // pre-rendered HTML for in-app changelog UI. Both ride to the page untouched —
680
+ // the feed is the developer's own signed content.
681
+ fn emit_available(handle: &tauri::AppHandle, m: &Manifest) {
682
+ let _ = handle.emit_to("main", "everywhere:update-available",
683
+ serde_json::json!({ "version": m.version, "notes": m.notes,
684
+ "notes_html": m.notes_html, "published_at": m.published_at }));
685
+ }
686
+
687
+ fn emit_error(handle: &tauri::AppHandle, message: &str) {
688
+ eprintln!("[updater] {message}");
689
+ let _ = handle.emit_to("main", "everywhere:update-error", serde_json::json!({ "message": message }));
690
+ }
691
+
692
+ fn sha256_file(path: &Path) -> Result<String, String> {
693
+ use sha2::{Digest, Sha256};
694
+ use std::io::Read;
695
+
696
+ let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
697
+ let mut hasher = Sha256::new();
698
+ let mut buf = vec![0u8; 1 << 20];
699
+ loop {
700
+ let n = file.read(&mut buf).map_err(|e| e.to_string())?;
701
+ if n == 0 {
702
+ break;
703
+ }
704
+ hasher.update(&buf[..n]);
705
+ }
706
+ Ok(format!("{:x}", hasher.finalize()))
707
+ }
708
+
709
+ fn run_ok(cmd: &mut Command, what: &str) -> Result<(), String> {
710
+ let out = cmd.output().map_err(|e| format!("{what} failed to run: {e}"))?;
711
+ if out.status.success() {
712
+ Ok(())
713
+ } else {
714
+ Err(format!("{what} failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
715
+ }
716
+ }
717
+
718
+ // "1.2" -> 1.2.0; strips a leading v. Returns None only when hopeless.
719
+ fn lenient_semver(s: &str) -> Option<semver::Version> {
720
+ let s = s.trim().trim_start_matches('v');
721
+ semver::Version::parse(s).ok().or_else(|| {
722
+ let parts: Vec<&str> = s.split('.').collect();
723
+ match parts.len() {
724
+ 1 => semver::Version::parse(&format!("{s}.0.0")).ok(),
725
+ 2 => semver::Version::parse(&format!("{s}.0")).ok(),
726
+ _ => None,
727
+ }
728
+ })
729
+ }
730
+
731
+ fn os_name() -> &'static str {
732
+ std::env::consts::OS // "macos" | "windows" | "linux" — matches the bucket layout
733
+ }
734
+
735
+ fn arch_name() -> &'static str {
736
+ match std::env::consts::ARCH {
737
+ "aarch64" => "arm64",
738
+ other => other, // "x86_64" stays as-is
739
+ }
740
+ }
741
+
742
+ // Single-quote shell escaping for the helper script.
743
+ fn shq(path: &Path) -> String {
744
+ format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
745
+ }