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