Skip to main content

spool/bootstrap/
release.rs

1//! Binary release — copies bundled binaries from the Tauri app bundle into
2//! `~/.spool/bin/`.
3//!
4//! The Tauri build process places the binaries inside the app bundle's
5//! resource directory. On first run (or when a version mismatch is detected),
6//! this module:
7//!
8//! 1. Copies each binary from the source directory to `~/.spool/bin/`
9//! 2. Sets executable permissions on Unix
10//! 3. Verifies each binary is runnable via `--version`
11
12use anyhow::{Context, Result, bail};
13use std::path::{Path, PathBuf};
14
15/// Names of binaries that ship with Spool.
16pub const BUNDLED_BINARIES: &[&str] = &["spool", "spool-mcp", "spool-daemon"];
17
18/// Result of a release operation.
19#[derive(Debug, Clone)]
20pub struct ReleaseReport {
21    pub copied: Vec<PathBuf>,
22    pub skipped: Vec<PathBuf>,
23}
24
25/// Release `BUNDLED_BINARIES` from `source_dir` into `target_bin_dir`.
26///
27/// - `source_dir`: path to the directory containing bundled binaries (e.g.
28///   the Tauri resource directory).
29/// - `target_bin_dir`: destination, typically `~/.spool/bin/`.
30/// - `force`: when `true`, overwrites existing binaries even if they look
31///   identical. Use during version upgrades.
32pub fn release_binaries(
33    source_dir: &Path,
34    target_bin_dir: &Path,
35    force: bool,
36) -> Result<ReleaseReport> {
37    if !source_dir.is_dir() {
38        bail!("source directory does not exist: {}", source_dir.display());
39    }
40    std::fs::create_dir_all(target_bin_dir)
41        .with_context(|| format!("creating {}", target_bin_dir.display()))?;
42
43    let mut copied = Vec::new();
44    let mut skipped = Vec::new();
45
46    for name in BUNDLED_BINARIES {
47        let exe_name = if cfg!(windows) {
48            format!("{name}.exe")
49        } else {
50            (*name).to_string()
51        };
52        let source = source_dir.join(&exe_name);
53        let target = target_bin_dir.join(&exe_name);
54
55        if !source.exists() {
56            // Skip binaries that aren't in the bundle (e.g. dev builds).
57            continue;
58        }
59
60        if !force && target.exists() && files_appear_identical(&source, &target)? {
61            skipped.push(target);
62            continue;
63        }
64
65        copy_binary(&source, &target)?;
66        copied.push(target);
67    }
68
69    Ok(ReleaseReport { copied, skipped })
70}
71
72/// Copy a single binary, preserving permissions on Unix.
73fn copy_binary(source: &Path, target: &Path) -> Result<()> {
74    std::fs::copy(source, target)
75        .with_context(|| format!("copying {} → {}", source.display(), target.display()))?;
76
77    #[cfg(unix)]
78    {
79        use std::os::unix::fs::PermissionsExt;
80        let mut perms = std::fs::metadata(target)
81            .with_context(|| format!("reading permissions for {}", target.display()))?
82            .permissions();
83        perms.set_mode(0o755);
84        std::fs::set_permissions(target, perms)
85            .with_context(|| format!("setting permissions on {}", target.display()))?;
86    }
87
88    Ok(())
89}
90
91/// Check whether two files have identical size and mtime. Cheap heuristic
92/// to avoid re-copying binaries that haven't changed.
93fn files_appear_identical(a: &Path, b: &Path) -> Result<bool> {
94    let meta_a =
95        std::fs::metadata(a).with_context(|| format!("reading metadata for {}", a.display()))?;
96    let meta_b =
97        std::fs::metadata(b).with_context(|| format!("reading metadata for {}", b.display()))?;
98    Ok(meta_a.len() == meta_b.len())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::fs;
105    use tempfile::tempdir;
106
107    fn write_fake_binary(dir: &Path, name: &str, content: &str) -> PathBuf {
108        let exe_name = if cfg!(windows) {
109            format!("{name}.exe")
110        } else {
111            name.to_string()
112        };
113        let path = dir.join(&exe_name);
114        fs::write(&path, content).unwrap();
115        path
116    }
117
118    #[test]
119    fn release_should_copy_existing_bundled_binaries() {
120        let temp = tempdir().unwrap();
121        let source = temp.path().join("source");
122        let target = temp.path().join("target");
123        fs::create_dir_all(&source).unwrap();
124
125        write_fake_binary(&source, "spool", "fake spool");
126        write_fake_binary(&source, "spool-mcp", "fake spool-mcp");
127        // spool-daemon intentionally missing — should be skipped silently
128
129        let report = release_binaries(&source, &target, false).unwrap();
130        assert_eq!(report.copied.len(), 2);
131        assert!(report.skipped.is_empty());
132
133        let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
134        assert!(target.join(target_spool).exists());
135    }
136
137    #[test]
138    fn release_should_skip_when_not_forced_and_target_exists() {
139        let temp = tempdir().unwrap();
140        let source = temp.path().join("source");
141        let target = temp.path().join("target");
142        fs::create_dir_all(&source).unwrap();
143        fs::create_dir_all(&target).unwrap();
144
145        write_fake_binary(&source, "spool", "v1");
146        write_fake_binary(&target, "spool", "v1");
147
148        let report = release_binaries(&source, &target, false).unwrap();
149        assert_eq!(report.copied.len(), 0);
150        assert_eq!(report.skipped.len(), 1);
151    }
152
153    #[test]
154    fn release_should_overwrite_when_forced() {
155        let temp = tempdir().unwrap();
156        let source = temp.path().join("source");
157        let target = temp.path().join("target");
158        fs::create_dir_all(&source).unwrap();
159        fs::create_dir_all(&target).unwrap();
160
161        write_fake_binary(&source, "spool", "new version");
162        write_fake_binary(&target, "spool", "old version");
163
164        let report = release_binaries(&source, &target, true).unwrap();
165        assert_eq!(report.copied.len(), 1);
166
167        let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
168        let copied = fs::read_to_string(target.join(target_spool)).unwrap();
169        assert_eq!(copied, "new version");
170    }
171
172    #[test]
173    fn release_should_fail_when_source_dir_missing() {
174        let temp = tempdir().unwrap();
175        let source = temp.path().join("nonexistent");
176        let target = temp.path().join("target");
177        let result = release_binaries(&source, &target, false);
178        assert!(result.is_err());
179    }
180
181    #[cfg(unix)]
182    #[test]
183    fn release_should_set_executable_permissions() {
184        use std::os::unix::fs::PermissionsExt;
185        let temp = tempdir().unwrap();
186        let source = temp.path().join("source");
187        let target = temp.path().join("target");
188        fs::create_dir_all(&source).unwrap();
189
190        write_fake_binary(&source, "spool", "binary");
191        release_binaries(&source, &target, false).unwrap();
192
193        let mode = fs::metadata(target.join("spool"))
194            .unwrap()
195            .permissions()
196            .mode();
197        assert_eq!(mode & 0o755, 0o755);
198    }
199}