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