1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::{Mutex, OnceLock};
5
6use anyhow::{anyhow, Context, Result};
7use hashtree_updater::{
8 DownloadOptions, UpdateAsset, UpdateCheck, UpdateManifest, UpdateRef, UpdateTarget,
9};
10use serde::Deserialize;
11
12use crate::update_announcements::{
13 build_cached_update_downloader, check_secure_update, secure_update_ref, AvailableUpdateResolver,
14};
15
16static VERIFIED_APP_UPDATE: OnceLock<
19 Mutex<Option<(UpdateRef, AvailableUpdateResolver, UpdateCheck)>>,
20> = OnceLock::new();
21
22const HTREE_MANIFEST_URL: &str = "https://upload.iris.to/npub1399g0q2gtwjcglyjcg3jw3rcllqhm375pwases5hkvqa56aqe5wsz2eaap/releases%2Firis-chat-rs/latest/release.json";
23const UPDATE_CONNECT_TIMEOUT_SECS: &str = "4";
24const UPDATE_MANIFEST_TIMEOUT_SECS: &str = "8";
25const UPDATE_DOWNLOAD_TIMEOUT_SECS: &str = "180";
26const UPDATE_USER_AGENT: &str = "iris-chat-updater";
27const SECURE_SOURCE_NAME: &str = "hashtree-nostr-blossom";
28const MANIFEST_SOURCE_NAME: &str = "hashtree-release-json";
29
30#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
31pub struct IrisDesktopUpdateResult {
32 pub ok: bool,
33 pub error: Option<String>,
34 pub available: bool,
35 pub current_version: String,
36 pub latest_version: String,
37 pub tag: String,
38 pub asset: String,
39 pub source: String,
40 pub verified: bool,
41 pub url: Option<String>,
42 pub path: Option<String>,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46#[allow(dead_code)]
47enum UpdateSource {
48 Auto,
49 Hashtree,
50 Manifest,
51}
52
53#[derive(Debug)]
54enum UpdateOperation {
55 Check,
56 Download { download_dir: Option<PathBuf> },
57}
58
59#[derive(Debug, Deserialize)]
60struct ReleaseManifest {
61 #[serde(alias = "tag_name")]
62 tag: String,
63 assets: Vec<ReleaseAsset>,
64}
65
66#[derive(Clone, Debug, Deserialize)]
67struct ReleaseAsset {
68 name: String,
69 #[serde(alias = "browser_download_url")]
70 path: String,
71}
72
73struct SelectedManifestAsset {
74 manifest: ReleaseManifest,
75 asset: ReleaseAsset,
76 asset_url: String,
77 update_available: bool,
78}
79
80impl IrisDesktopUpdateResult {
81 fn error(message: String) -> Self {
82 Self {
83 ok: false,
84 error: Some(message),
85 available: false,
86 current_version: current_version().to_string(),
87 latest_version: String::new(),
88 tag: String::new(),
89 asset: String::new(),
90 source: String::new(),
91 verified: false,
92 url: None,
93 path: None,
94 }
95 }
96
97 fn from_error(error: anyhow::Error) -> Self {
98 Self::error(format!("{error:#}"))
99 }
100}
101
102#[uniffi::export]
103pub fn iris_desktop_update_check() -> IrisDesktopUpdateResult {
104 crate::ffi_or(
105 "iris_desktop_update_check",
106 IrisDesktopUpdateResult::error("Update check failed".to_string()),
107 || match run_app_update(UpdateOperation::Check, UpdateSource::Auto) {
108 Ok(result) => result,
109 Err(error) => IrisDesktopUpdateResult::from_error(error),
110 },
111 )
112}
113
114#[uniffi::export]
115pub fn iris_desktop_update_download(download_dir: Option<String>) -> IrisDesktopUpdateResult {
116 crate::ffi_or(
117 "iris_desktop_update_download",
118 IrisDesktopUpdateResult::error("Update download failed".to_string()),
119 || {
120 let download_dir = download_dir
121 .as_deref()
122 .map(str::trim)
123 .filter(|value| !value.is_empty())
124 .map(PathBuf::from);
125 match run_app_update(
126 UpdateOperation::Download { download_dir },
127 UpdateSource::Auto,
128 ) {
129 Ok(result) => result,
130 Err(error) => IrisDesktopUpdateResult::from_error(error),
131 }
132 },
133 )
134}
135
136fn run_app_update(
137 operation: UpdateOperation,
138 source: UpdateSource,
139) -> Result<IrisDesktopUpdateResult> {
140 if should_use_secure_hashtree(source) {
141 run_secure_update(operation)
142 } else {
143 run_manifest_update(operation)
144 }
145}
146
147fn should_use_secure_hashtree(source: UpdateSource) -> bool {
148 let manifest_override = std::env::var("IRIS_UPDATE_MANIFEST_URL").ok();
149 update_source_uses_secure_hashtree(source, manifest_override.as_deref())
150}
151
152fn update_source_uses_secure_hashtree(
153 source: UpdateSource,
154 manifest_override: Option<&str>,
155) -> bool {
156 match source {
157 UpdateSource::Hashtree => true,
158 UpdateSource::Manifest => false,
159 UpdateSource::Auto => manifest_override
160 .filter(|value| !value.trim().is_empty())
161 .is_none(),
162 }
163}
164
165fn run_secure_update(operation: UpdateOperation) -> Result<IrisDesktopUpdateResult> {
166 let runtime = tokio::runtime::Builder::new_multi_thread()
167 .enable_all()
168 .build()
169 .context("failed to start update runtime")?;
170 let task = runtime.spawn(async move { Box::pin(run_secure_update_async(operation)).await });
175 runtime
176 .block_on(task)
177 .context("update runtime task failed")?
178}
179
180async fn run_secure_update_async(operation: UpdateOperation) -> Result<IrisDesktopUpdateResult> {
181 let reference = secure_update_ref()?;
182 let cached = VERIFIED_APP_UPDATE
183 .get_or_init(|| Mutex::new(None))
184 .lock()
185 .map_err(|_| anyhow!("update selection lock poisoned"))?
186 .as_ref()
187 .filter(|(key, _, _)| key == &reference)
188 .map(|(_, resolver, check)| (resolver.clone(), check.clone()));
189 let (updater, mut check) = if let Some((resolver, check)) =
190 cached.filter(|_| matches!(operation, UpdateOperation::Download { .. }))
191 {
192 let updater = build_cached_update_downloader(resolver)?;
193 (updater, check)
194 } else {
195 check_secure_update(
196 current_version().to_string(),
197 UpdateTarget::new(current_target()),
198 )
199 .await
200 .context("failed to resolve signed release")?
201 };
202 let asset = preferred_secure_app_asset(&check.manifest).ok_or_else(|| {
203 anyhow!(
204 "release {} has no app update for {}",
205 display_manifest_tag(&check.manifest),
206 current_target()
207 )
208 })?;
209 check.asset = Some(asset.clone());
210 *VERIFIED_APP_UPDATE
211 .get_or_init(|| Mutex::new(None))
212 .lock()
213 .map_err(|_| anyhow!("update selection lock poisoned"))? =
214 Some((reference, updater.resolver().clone(), check.clone()));
215 let tag = display_manifest_tag(&check.manifest);
216 let available = version_is_newer(&tag, current_version());
217
218 match operation {
219 UpdateOperation::Check => Ok(update_result(
220 available,
221 &tag,
222 &asset.name,
223 SECURE_SOURCE_NAME,
224 true,
225 None,
226 None,
227 )),
228 UpdateOperation::Download { download_dir } => {
229 let temp_dir = create_temp_dir("iris-update")?;
230 let destination =
231 selected_download_path(download_dir.as_deref(), &asset.name, &temp_dir)?;
232 let downloaded = updater
233 .download(&check, DownloadOptions::default(), None)
234 .await
235 .with_context(|| format!("failed to download verified update {}", asset.name))?;
236 write_downloaded_asset(&destination, &downloaded.bytes)?;
237 Ok(update_result(
238 available,
239 &tag,
240 &asset.name,
241 SECURE_SOURCE_NAME,
242 true,
243 None,
244 Some(&destination),
245 ))
246 }
247 }
248}
249
250fn run_manifest_update(operation: UpdateOperation) -> Result<IrisDesktopUpdateResult> {
251 let selection = manifest_selection()?;
252 match operation {
253 UpdateOperation::Check => Ok(update_result(
254 selection.update_available,
255 &selection.manifest.tag,
256 &selection.asset.name,
257 MANIFEST_SOURCE_NAME,
258 false,
259 Some(&selection.asset_url),
260 None,
261 )),
262 UpdateOperation::Download { download_dir } => {
263 let temp_dir = create_temp_dir("iris-update")?;
264 let destination =
265 selected_download_path(download_dir.as_deref(), &selection.asset.name, &temp_dir)?;
266 download_asset(&selection.asset_url, &destination)?;
267 Ok(update_result(
268 selection.update_available,
269 &selection.manifest.tag,
270 &selection.asset.name,
271 MANIFEST_SOURCE_NAME,
272 false,
273 Some(&selection.asset_url),
274 Some(&destination),
275 ))
276 }
277 }
278}
279
280fn update_result(
281 available: bool,
282 tag: &str,
283 asset: &str,
284 source: &'static str,
285 verified: bool,
286 url: Option<&str>,
287 path: Option<&Path>,
288) -> IrisDesktopUpdateResult {
289 IrisDesktopUpdateResult {
290 ok: true,
291 error: None,
292 available,
293 current_version: current_version().to_string(),
294 latest_version: tag.trim_start_matches(['v', 'V']).to_string(),
295 tag: tag.to_string(),
296 asset: asset.to_string(),
297 source: source.to_string(),
298 verified,
299 url: url.map(ToOwned::to_owned),
300 path: path.map(|value| value.display().to_string()),
301 }
302}
303
304fn manifest_selection() -> Result<SelectedManifestAsset> {
305 let manifest_url = manifest_url();
306 let manifest = fetch_manifest(&manifest_url)?;
307 let asset = preferred_app_asset(&manifest.assets).ok_or_else(|| {
308 anyhow!(
309 "release {} has no app update for {}",
310 manifest.tag,
311 current_target()
312 )
313 })?;
314 let asset_url = manifest_asset_url(&manifest_url, &asset.path);
315 let update_available = version_is_newer(&manifest.tag, current_version());
316 Ok(SelectedManifestAsset {
317 manifest,
318 asset,
319 asset_url,
320 update_available,
321 })
322}
323
324fn manifest_url() -> String {
325 std::env::var("IRIS_UPDATE_MANIFEST_URL")
326 .ok()
327 .filter(|value| !value.trim().is_empty())
328 .unwrap_or_else(|| HTREE_MANIFEST_URL.to_string())
329}
330
331fn fetch_manifest(url: &str) -> Result<ReleaseManifest> {
332 let bytes = read_url(url, UPDATE_MANIFEST_TIMEOUT_SECS)
333 .with_context(|| format!("failed to fetch release manifest from {url}"))?;
334 serde_json::from_slice(&bytes).context("failed to parse release manifest")
335}
336
337fn preferred_app_asset(assets: &[ReleaseAsset]) -> Option<ReleaseAsset> {
338 assets
339 .iter()
340 .find(|asset| app_asset_name_matches_current_target(&asset.name))
341 .cloned()
342}
343
344fn preferred_secure_app_asset(manifest: &UpdateManifest) -> Option<UpdateAsset> {
345 manifest
346 .assets
347 .iter()
348 .find(|asset| app_asset_name_matches_current_target(&asset.name))
349 .cloned()
350}
351
352fn app_asset_name_matches_current_target(name: &str) -> bool {
353 let lower = name.to_ascii_lowercase();
354 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
355 {
356 lower.ends_with("-macos-arm64.app.tar.gz")
357 || lower.ends_with("-macos-arm64.dmg")
358 || lower.ends_with("-macos-arm64.zip")
359 }
360 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
361 {
362 lower.ends_with("-linux-x64.deb") || lower.ends_with("-linux-x64.tar.gz")
363 }
364 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
365 {
366 lower.ends_with("-linux-arm64.deb") || lower.ends_with("-linux-arm64.tar.gz")
367 }
368 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
369 {
370 lower.ends_with("-windows-x64-setup.exe")
371 }
372 #[cfg(not(any(
373 all(target_os = "macos", target_arch = "aarch64"),
374 all(target_os = "linux", target_arch = "x86_64"),
375 all(target_os = "linux", target_arch = "aarch64"),
376 all(target_os = "windows", target_arch = "x86_64"),
377 )))]
378 {
379 let _ = lower;
380 false
381 }
382}
383
384fn display_manifest_tag(manifest: &UpdateManifest) -> String {
385 manifest
386 .tag
387 .clone()
388 .filter(|tag| !tag.trim().is_empty())
389 .unwrap_or_else(|| format!("v{}", manifest.effective_version()))
390}
391
392fn current_target() -> &'static str {
393 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
394 {
395 "aarch64-apple-darwin"
396 }
397 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
398 {
399 "x86_64-apple-darwin"
400 }
401 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
402 {
403 "x86_64-unknown-linux-gnu"
404 }
405 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
406 {
407 "aarch64-unknown-linux-gnu"
408 }
409 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
410 {
411 "x86_64-pc-windows-msvc"
412 }
413 #[cfg(not(any(
414 all(target_os = "macos", target_arch = "aarch64"),
415 all(target_os = "macos", target_arch = "x86_64"),
416 all(target_os = "linux", target_arch = "x86_64"),
417 all(target_os = "linux", target_arch = "aarch64"),
418 all(target_os = "windows", target_arch = "x86_64"),
419 )))]
420 {
421 "unsupported"
422 }
423}
424
425fn manifest_asset_url(manifest_url: &str, path: &str) -> String {
426 if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
427 return path.to_string();
428 }
429 if Path::new(path).is_absolute() {
430 return format!("file://{path}");
431 }
432 let base = manifest_url
433 .rsplit_once('/')
434 .map(|(base, _)| base)
435 .unwrap_or(manifest_url);
436 format!("{}/{}", base, path.trim_start_matches('/'))
437}
438
439fn download_asset(url: &str, destination: &Path) -> Result<()> {
440 if let Some(parent) = destination.parent() {
441 fs::create_dir_all(parent)
442 .with_context(|| format!("failed to create {}", parent.display()))?;
443 }
444 if let Some(path) = local_file_url_path(url)? {
445 fs::copy(&path, destination).with_context(|| {
446 format!(
447 "failed to copy update from {} to {}",
448 path.display(),
449 destination.display()
450 )
451 })?;
452 return Ok(());
453 }
454 let output = curl_command(UPDATE_DOWNLOAD_TIMEOUT_SECS)
455 .arg("-o")
456 .arg(destination)
457 .arg(url)
458 .output()
459 .with_context(|| format!("failed to run curl for {url}"))?;
460 if !output.status.success() {
461 return Err(anyhow!(
462 "{}",
463 command_error("update download failed", &output)
464 ));
465 }
466 Ok(())
467}
468
469fn read_url(url: &str, max_time: &str) -> Result<Vec<u8>> {
470 if let Some(path) = local_file_url_path(url)? {
471 return fs::read(&path).with_context(|| format!("failed to read {}", path.display()));
472 }
473 let output = curl_command(max_time)
474 .arg(url)
475 .output()
476 .with_context(|| format!("failed to run curl for {url}"))?;
477 if !output.status.success() {
478 return Err(anyhow!("{}", command_error("update check failed", &output)));
479 }
480 Ok(output.stdout)
481}
482
483fn local_file_url_path(value: &str) -> Result<Option<PathBuf>> {
484 if value.starts_with("file://") {
485 let url = url::Url::parse(value).with_context(|| format!("invalid file URL: {value}"))?;
486 return url
487 .to_file_path()
488 .map(Some)
489 .map_err(|_| anyhow!("invalid file URL path: {value}"));
490 }
491 let path = Path::new(value);
492 if path.is_absolute() {
493 return Ok(Some(path.to_path_buf()));
494 }
495 Ok(None)
496}
497
498fn curl_command(max_time: &str) -> Command {
499 let mut command = Command::new("curl");
500 command.args([
501 "-fsSL",
502 "--connect-timeout",
503 UPDATE_CONNECT_TIMEOUT_SECS,
504 "--max-time",
505 max_time,
506 "-H",
507 ]);
508 command.arg(format!("User-Agent: {UPDATE_USER_AGENT}"));
509 command
510}
511
512fn selected_download_path(
513 download_dir: Option<&Path>,
514 asset_name: &str,
515 temp_dir: &Path,
516) -> Result<PathBuf> {
517 let file_name = safe_file_name(asset_name);
518 let parent = download_dir
519 .map(Path::to_path_buf)
520 .or_else(|| {
521 std::env::current_exe()
522 .ok()
523 .and_then(|path| path.parent().map(Path::to_path_buf))
524 })
525 .unwrap_or_else(|| temp_dir.to_path_buf());
526 fs::create_dir_all(&parent)
527 .with_context(|| format!("failed to create {}", parent.display()))?;
528 Ok(parent.join(file_name))
529}
530
531fn write_downloaded_asset(destination: &Path, bytes: &[u8]) -> Result<()> {
532 if let Some(parent) = destination.parent() {
533 fs::create_dir_all(parent)
534 .with_context(|| format!("failed to create {}", parent.display()))?;
535 }
536 fs::write(destination, bytes).with_context(|| {
537 format!(
538 "failed to write verified update to {}",
539 destination.display()
540 )
541 })
542}
543
544fn current_version() -> &'static str {
545 crate::core::app_version_string()
546}
547
548fn create_temp_dir(prefix: &str) -> Result<PathBuf> {
549 let base = std::env::temp_dir();
550 for attempt in 0..128u32 {
551 let path = base.join(format!(
552 "{prefix}-{}-{}-{attempt}",
553 std::process::id(),
554 unix_timestamp()
555 ));
556 match fs::create_dir(&path) {
557 Ok(()) => return Ok(path),
558 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
559 Err(error) => {
560 return Err(error).with_context(|| format!("failed to create {}", path.display()));
561 }
562 }
563 }
564 Err(anyhow!("failed to allocate temporary update directory"))
565}
566
567fn safe_file_name(name: &str) -> String {
568 let value = name
569 .chars()
570 .map(|ch| {
571 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
572 ch
573 } else {
574 '_'
575 }
576 })
577 .collect::<String>();
578 if value.is_empty() {
579 "iris-update".to_string()
580 } else {
581 value
582 }
583}
584
585fn command_error(prefix: &str, output: &std::process::Output) -> String {
586 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
587 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
588 if !stderr.is_empty() {
589 format!("{prefix}: {stderr}")
590 } else if !stdout.is_empty() {
591 format!("{prefix}: {stdout}")
592 } else {
593 format!("{prefix}: exit {}", output.status)
594 }
595}
596
597fn version_is_newer(candidate: &str, current: &str) -> bool {
598 if is_dev_placeholder_version(current) {
599 return false;
600 }
601 let left = version_parts(candidate);
602 let right = version_parts(current);
603 for index in 0..left.len().max(right.len()) {
604 let left_value = left.get(index).copied().unwrap_or_default();
605 let right_value = right.get(index).copied().unwrap_or_default();
606 if left_value != right_value {
607 return left_value > right_value;
608 }
609 }
610 false
611}
612
613fn is_dev_placeholder_version(value: &str) -> bool {
614 version_parts(value)
615 .first()
616 .is_none_or(|major| *major < 2000)
617}
618
619fn version_parts(value: &str) -> Vec<u32> {
620 value
621 .trim_matches(|ch: char| ch == 'v' || ch == 'V' || ch.is_whitespace())
622 .split(|ch: char| !ch.is_ascii_digit())
623 .filter(|part| !part.is_empty())
624 .map(|part| part.parse::<u32>().unwrap_or_default())
625 .collect()
626}
627
628fn unix_timestamp() -> u64 {
629 std::time::SystemTime::now()
630 .duration_since(std::time::UNIX_EPOCH)
631 .map_or(0, |duration| duration.as_secs())
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn date_versions_skip_dev_placeholders() {
640 assert!(version_is_newer("v2026.5.18.6", "2026.5.18.5"));
641 assert!(version_is_newer("v2026.9.22.1", "2026.9.22"));
642 assert!(version_is_newer("v2026.9.22.10", "2026.9.22.2"));
643 assert!(!version_is_newer("v2026.9.22.1", "2026.9.22.1"));
644 assert!(!version_is_newer("v2026.5.18.6", "0.1.30"));
645 }
646
647 #[test]
648 fn update_failure_keeps_the_underlying_cause() {
649 let error = anyhow!("unknown asset kind: aab").context("failed to resolve signed release");
650 assert_eq!(
651 IrisDesktopUpdateResult::from_error(error).error.as_deref(),
652 Some("failed to resolve signed release: unknown asset kind: aab")
653 );
654 }
655
656 #[test]
657 fn relative_asset_urls_use_manifest_directory() {
658 assert_eq!(
659 manifest_asset_url(
660 "https://example.invalid/releases/iris-chat-rs/latest/release.json",
661 "assets/iris.tgz",
662 ),
663 "https://example.invalid/releases/iris-chat-rs/latest/assets/iris.tgz"
664 );
665 }
666
667 #[test]
668 fn app_assets_do_not_match_cli_archives() {
669 assert!(!app_asset_name_matches_current_target(&format!(
670 "iris-{}.tar.gz",
671 current_target()
672 )));
673 }
674
675 #[test]
676 fn explicit_update_sources_override_manifest_environment() {
677 assert!(!update_source_uses_secure_hashtree(
678 UpdateSource::Auto,
679 Some("file:///tmp/release.json"),
680 ));
681 assert!(update_source_uses_secure_hashtree(
682 UpdateSource::Hashtree,
683 Some("file:///tmp/release.json"),
684 ));
685 assert!(!update_source_uses_secure_hashtree(
686 UpdateSource::Manifest,
687 None,
688 ));
689 assert!(update_source_uses_secure_hashtree(
690 UpdateSource::Auto,
691 Some(" "),
692 ));
693 }
694}
695
696#[cfg(test)]
697#[path = "desktop_update_startup_tests.rs"]
698mod startup_tests;