Skip to main content

tauri_latest_json/
lib.rs

1use chrono::Utc;
2use serde_json::{json, Value};
3use std::{
4    collections::HashMap,
5    fs,
6    io::Read,
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11fn read_version() -> Result<String, Box<dyn std::error::Error>> {
12    let cwd = std::env::current_dir()?;
13    read_version_from_dir(&cwd)
14}
15
16fn read_version_from_dir(base: &Path) -> Result<String, Box<dyn std::error::Error>> {
17    // Try package.json first
18    let pkg_path = base.join("package.json");
19    if pkg_path.exists() {
20        let pkg_str = fs::read_to_string(&pkg_path)?;
21        let pkg_json: serde_json::Value = serde_json::from_str(&pkg_str)?;
22        if let Some(ver) = pkg_json["version"].as_str() {
23            return Ok(ver.to_string());
24        }
25    }
26
27    // Fallback to Cargo.toml
28    let cargo_path = base.join("Cargo.toml");
29    if cargo_path.exists() {
30        let cargo_str = fs::read_to_string(&cargo_path)?;
31        let mut in_package = false;
32        for raw_line in cargo_str.lines() {
33            let line = raw_line.trim();
34            if line.starts_with('[') && line.ends_with(']') {
35                in_package = line == "[package]";
36                continue;
37            }
38            if in_package && line.starts_with("version") {
39                if let Some(eq_pos) = line.find('=') {
40                    let version = line[eq_pos + 1..].trim().trim_matches('"').to_string();
41                    if !version.is_empty() {
42                        return Ok(version);
43                    }
44                }
45            }
46        }
47    }
48
49    Err("Could not find version in package.json or Cargo.toml".into())
50}
51
52/// Generates `latest.json` by auto-detecting the Tauri bundle dir,
53/// reading version + public key from config, signing installers,
54/// and verifying signatures against the configured public key.
55pub fn generate_latest_json_auto(
56    download_url_base: &str,
57    notes: &str,
58) -> Result<(), Box<dyn std::error::Error>> {
59    let bundle_dir = detect_bundle_dir()?;
60    let tauri_conf_path = detect_tauri_conf_path()?;
61
62    let public_key = read_public_key(&tauri_conf_path)?;
63    generate_latest_json(&bundle_dir, &public_key, download_url_base, notes)
64}
65
66/// Generates `latest.json` from a given bundle dir and paths.
67pub fn generate_latest_json(
68    bundle_dir: &Path,
69    public_key: &str,
70    download_url_base: &str,
71    notes: &str,
72) -> Result<(), Box<dyn std::error::Error>> {
73    // === 1. Read version from Cargo.toml ===
74    let version = read_version()?;
75
76    // === 2. Find installers ===
77    let installers = find_installers(&bundle_dir)?;
78    if installers.is_empty() {
79        return Err("No installers found".into());
80    }
81
82    // === 3. Build platforms map ===
83    let mut platforms = HashMap::new();
84    for installer in installers {
85        let installer_name = installer.file_name().unwrap().to_str().unwrap();
86        let platform_key = detect_platform_key(installer_name);
87
88        // Sign installer
89        let signature_path = find_signatures(&bundle_dir)?;
90        let sig_path = signature_path
91            .get(&platform_key)
92            .ok_or_else(|| format!("Signature not found for platform {}", platform_key))?;
93        let mut f_sig = std::fs::File::open(sig_path)?;
94        let mut signature = String::new();
95        f_sig.read_to_string(&mut signature)?;
96
97        // Verify signature
98        // verify_signature(&installer, &signature, public_key)?;
99
100        // Detect platform key
101
102        platforms.insert(
103            platform_key.to_string(),
104            json!({
105                "signature": signature,
106                "url": format!("{}/{}", download_url_base, installer_name)
107            }),
108        );
109    }
110
111    // === 4. Generate latest.json ===
112    let latest_json = json!({
113        "version": version,
114        "notes": notes,
115        "pub_date": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
116        "platforms": platforms
117    });
118
119    fs::write("latest.json", serde_json::to_string_pretty(&latest_json)?)?;
120    println!(
121        "✅ latest.json generated at {}",
122        std::env::current_dir()?.display()
123    );
124    Ok(())
125}
126
127/// Reads public key from tauri.conf.json
128fn read_public_key(conf_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
129    let conf_str = fs::read_to_string(conf_path)?;
130    let conf_json: Value = serde_json::from_str(&conf_str)?;
131    let public_key = conf_json["plugins"]["updater"]["pubkey"]
132        .as_str()
133        .ok_or("No public key found in tauri.conf.json")?;
134    Ok(public_key.to_string())
135}
136
137/// Reads `tauri.conf.json` and figures out the `bundle` directory.
138fn detect_bundle_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
139    let current_dir = std::env::current_dir()?;
140    let candidates = [
141        current_dir.join("target").join("release").join("bundle"),
142        current_dir
143            .join("src-tauri")
144            .join("target")
145            .join("release")
146            .join("bundle"),
147        current_dir
148            .join("..")
149            .join("src-tauri")
150            .join("target")
151            .join("release")
152            .join("bundle"),
153        current_dir.join("target").join("debug").join("bundle"),
154        current_dir
155            .join("src-tauri")
156            .join("target")
157            .join("debug")
158            .join("bundle"),
159    ];
160
161    for path in candidates {
162        if path.exists() {
163            return Ok(path);
164        }
165    }
166
167    Err("Could not detect bundle dir. Build your Tauri app to produce target/*/bundle.".into())
168}
169
170fn find_installers(dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
171    let mut results = Vec::new();
172    for entry in walkdir::WalkDir::new(dir) {
173        let entry = entry?;
174        if entry.file_type().is_file() {
175            let fname = entry.file_name().to_string_lossy();
176            if fname.ends_with(".msi")
177                || fname.ends_with(".exe")
178                || fname.ends_with(".dmg")
179                || fname.ends_with(".AppImage")
180                || fname.ends_with(".deb")
181                || fname.ends_with(".rpm")
182                || fname.ends_with(".tar.gz")
183            {
184                results.push(entry.path().to_path_buf());
185            }
186        }
187    }
188    Ok(results)
189}
190
191fn find_signatures(
192    dir: &Path,
193) -> Result<HashMap<&'static str, PathBuf>, Box<dyn std::error::Error>> {
194    let mut results = HashMap::new();
195    for entry in walkdir::WalkDir::new(dir) {
196        let entry = entry?;
197        if entry.file_type().is_file() {
198            let fname = entry.file_name().to_string_lossy();
199            if fname.ends_with(".sig") {
200                let platform = detect_platform_key(&fname.replace(".sig", ""));
201                results.insert(platform, entry.path().to_path_buf());
202            }
203        }
204    }
205    Ok(results)
206}
207
208fn detect_tauri_conf_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
209    let current_dir = std::env::current_dir()?;
210    let candidates = [
211        current_dir.join("tauri.conf.json"),
212        current_dir.join("src-tauri").join("tauri.conf.json"),
213        current_dir
214            .join("..")
215            .join("src-tauri")
216            .join("tauri.conf.json"),
217    ];
218    for c in candidates {
219        if c.exists() {
220            return Ok(c);
221        }
222    }
223    Err("Could not find tauri.conf.json. Provide it at project root or src-tauri/.".into())
224}
225
226fn verify_signature(
227    installer: &Path,
228    signature: &str,
229    public_key: &str,
230) -> Result<(), Box<dyn std::error::Error>> {
231    let output = Command::new("tauri")
232        .args([
233            "signer",
234            "verify",
235            "--public-key",
236            public_key,
237            installer.to_str().unwrap(),
238            signature,
239        ])
240        .output()?;
241
242    if !output.status.success() {
243        return Err(format!(
244            "Signature verification failed for {:?}: {}",
245            installer,
246            String::from_utf8_lossy(&output.stderr)
247        )
248        .into());
249    }
250
251    Ok(())
252}
253
254fn detect_platform_key(filename: &str) -> &'static str {
255    let lower = filename.to_ascii_lowercase();
256    if lower.ends_with(".msi") || lower.ends_with(".exe") {
257        "windows-x86_64"
258    } else if lower.ends_with(".dmg") {
259        if lower.contains("aarch64") || lower.contains("arm64") {
260            "darwin-aarch64"
261        } else {
262            "darwin-x86_64"
263        }
264    } else if lower.ends_with(".appimage")
265        || lower.ends_with(".deb")
266        || lower.ends_with(".rpm")
267        || lower.ends_with(".tar.gz")
268    {
269        if lower.contains("aarch64") || lower.contains("arm64") {
270            "linux-aarch64"
271        } else {
272            "linux-x86_64"
273        }
274    } else {
275        "unknown"
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::fs::{create_dir_all, File};
283    use std::io::Write;
284    use std::time::{SystemTime, UNIX_EPOCH};
285
286    fn make_temp_dir() -> PathBuf {
287        let mut base = std::env::temp_dir();
288        let nanos = SystemTime::now()
289            .duration_since(UNIX_EPOCH)
290            .unwrap()
291            .as_nanos();
292        base.push(format!("tauri-latest-json-test-{}", nanos));
293        create_dir_all(&base).unwrap();
294        base
295    }
296
297    #[test]
298    fn test_detect_platform_key_variants() {
299        assert_eq!(
300            detect_platform_key("app_0.1.0_x64_en-US.msi"),
301            "windows-x86_64"
302        );
303        assert_eq!(
304            detect_platform_key("app_0.1.0_x64_en-US.exe"),
305            "windows-x86_64"
306        );
307        assert_eq!(detect_platform_key("app_0.1.0_x64.dmg"), "darwin-x86_64");
308        assert_eq!(detect_platform_key("app_0.1.0_arm64.dmg"), "darwin-aarch64");
309        assert_eq!(
310            detect_platform_key("AppImage-0.1.0-x86_64.AppImage"),
311            "linux-x86_64"
312        );
313        assert_eq!(
314            detect_platform_key("AppImage-0.1.0-arm64.AppImage"),
315            "linux-aarch64"
316        );
317        assert_eq!(detect_platform_key("app_0.1.0_amd64.deb"), "linux-x86_64");
318        assert_eq!(
319            detect_platform_key("app_0.1.0_aarch64.rpm"),
320            "linux-aarch64"
321        );
322        assert_eq!(detect_platform_key("app-0.1.0-x64.tar.gz"), "linux-x86_64");
323        assert_eq!(detect_platform_key("unknown.bin"), "unknown");
324    }
325
326    #[test]
327    fn test_read_version_prefers_package_json() {
328        let dir = make_temp_dir();
329
330        {
331            let mut f = File::create(dir.join("Cargo.toml")).unwrap();
332            writeln!(
333                f,
334                "[package]\nname = \"dummy\"\nversion = \"0.2.0\"\n\n[dependencies]\n"
335            )
336            .unwrap();
337        }
338
339        {
340            let mut f = File::create(dir.join("package.json")).unwrap();
341            writeln!(f, "{{\"name\":\"dummy\",\"version\":\"1.2.3\"}}").unwrap();
342        }
343
344        let v = read_version_from_dir(&dir).unwrap();
345        assert_eq!(v, "1.2.3");
346        std::fs::remove_dir_all(&dir).ok();
347    }
348
349    #[test]
350    fn test_read_version_from_cargo_toml() {
351        let dir = make_temp_dir();
352
353        {
354            let mut f = File::create(dir.join("Cargo.toml")).unwrap();
355            writeln!(
356                f,
357                "[package]\nname = \"dummy\"\nversion = \"9.9.9\"\n\n[dependencies]\n"
358            )
359            .unwrap();
360        }
361
362        let v = read_version_from_dir(&dir).unwrap();
363        assert_eq!(v, "9.9.9");
364        std::fs::remove_dir_all(&dir).ok();
365    }
366}