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