Skip to main content

iris_chat_core/
desktop_update.rs

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
16// Keep the signed, content-addressed selection offered to the user. Installing
17// it must not depend on discovering the same release a second time.
18static 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    // Swift's cooperative executor can call this FFI on a 512 KiB stack.
171    // Construct and poll the FIPS bootstrap future on a Tokio worker; the
172    // caller only waits on the small join handle. Boxing keeps the outer
173    // future small while the inner future is constructed on the worker.
174    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;