ruby_everywhere 0.1.7 → 0.1.8

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