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/npub1399g0q2gtwjcglyjcg3jw3rcllqhm375pwases5hkvqa56aqe5wsz2eaap/releases%2Firis-chat-rs/latest/release.json";
18const HTREE_UPDATE_REF: &str =
19    "htree://npub1399g0q2gtwjcglyjcg3jw3rcllqhm375pwases5hkvqa56aqe5wsz2eaap/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        .is_none_or(|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}