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::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}