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 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 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
52pub 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
66pub 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 let version = read_version()?;
75
76 let installers = find_installers(&bundle_dir)?;
78 if installers.is_empty() {
79 return Err("No installers found".into());
80 }
81
82 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 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 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 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
127fn 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
137fn 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}