Skip to main content

iris_chat_core/
desktop_update.rs

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}