1use std::path::PathBuf;
2use std::sync::mpsc;
3
4pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
5const REPO: &str = "Hexeption/FastPack";
6
7#[derive(Debug, Clone)]
8pub struct ReleaseInfo {
9 pub version: String,
10 pub notes: String,
12 pub asset_url: String,
14}
15
16pub enum UpdateMsg {
18 UpToDate { latest: String },
20 Available(ReleaseInfo),
22 Downloaded(PathBuf),
24 Error(String),
26}
27
28pub enum UpdateStatus {
30 Idle,
32 Checking,
34 UpToDate { latest: String },
36 Available(ReleaseInfo),
38 Downloading,
40 Downloaded(PathBuf),
42 Error(String),
44}
45
46pub fn spawn_check(tx: mpsc::Sender<UpdateMsg>) {
48 std::thread::spawn(move || {
49 let msg = match check_latest() {
50 Err(e) => UpdateMsg::Error(e),
51 Ok(None) => UpdateMsg::UpToDate {
52 latest: CURRENT_VERSION.to_string(),
53 },
54 Ok(Some(release)) => {
55 let current = parse_version(CURRENT_VERSION);
56 let latest = parse_version(&release.version);
57 if latest > current {
58 UpdateMsg::Available(release)
59 } else {
60 UpdateMsg::UpToDate {
61 latest: release.version,
62 }
63 }
64 }
65 };
66 tx.send(msg).ok();
67 });
68}
69
70pub fn spawn_download(release: ReleaseInfo, tx: mpsc::Sender<UpdateMsg>) {
72 std::thread::spawn(move || {
73 let msg = match download_asset(&release.asset_url) {
74 Ok(path) => UpdateMsg::Downloaded(path),
75 Err(e) => UpdateMsg::Error(e),
76 };
77 tx.send(msg).ok();
78 });
79}
80
81pub fn apply_update(downloaded: &std::path::Path) -> Result<(), String> {
83 do_apply(downloaded)
84}
85
86fn check_latest() -> Result<Option<ReleaseInfo>, String> {
87 let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
88 let resp = ureq::get(&url)
89 .header("User-Agent", &format!("fastpack/{CURRENT_VERSION}"))
90 .call()
91 .map_err(|e| e.to_string())?;
92
93 let body_str = resp
94 .into_body()
95 .read_to_string()
96 .map_err(|e| e.to_string())?;
97 let json: serde_json::Value = serde_json::from_str(&body_str).map_err(|e| e.to_string())?;
98
99 let version = json["tag_name"].as_str().unwrap_or("").to_string();
100 let notes = json["body"].as_str().unwrap_or("").to_string();
101
102 let asset_suffix = platform_asset_suffix();
103 let asset_url = json["assets"]
104 .as_array()
105 .and_then(|assets| {
106 assets.iter().find(|a| {
107 a["name"]
108 .as_str()
109 .is_some_and(|n| n.ends_with(asset_suffix))
110 })
111 })
112 .and_then(|a| a["browser_download_url"].as_str())
113 .map(|s| s.to_string());
114
115 let Some(asset_url) = asset_url else {
116 return Ok(None);
117 };
118
119 Ok(Some(ReleaseInfo {
120 version,
121 notes,
122 asset_url,
123 }))
124}
125
126fn download_asset(url: &str) -> Result<PathBuf, String> {
127 let resp = ureq::get(url)
128 .header("User-Agent", &format!("fastpack/{CURRENT_VERSION}"))
129 .call()
130 .map_err(|e| e.to_string())?;
131
132 let dest = std::env::temp_dir().join(download_filename());
133 let mut file = std::fs::File::create(&dest).map_err(|e| e.to_string())?;
134 let mut reader = resp.into_body().into_reader();
135 std::io::copy(&mut reader, &mut file).map_err(|e| e.to_string())?;
136 Ok(dest)
137}
138
139fn parse_version(v: &str) -> (u32, u32, u32) {
140 let v = v.trim_start_matches('v');
141 let mut parts = v.split('.').filter_map(|p| p.parse::<u32>().ok());
142 (
143 parts.next().unwrap_or(0),
144 parts.next().unwrap_or(0),
145 parts.next().unwrap_or(0),
146 )
147}
148
149#[cfg(target_os = "windows")]
150fn platform_asset_suffix() -> &'static str {
151 "windows-x86_64.msi"
152}
153
154#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
155fn platform_asset_suffix() -> &'static str {
156 "macos-aarch64.dmg"
157}
158
159#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
160fn platform_asset_suffix() -> &'static str {
161 "macos-x86_64.dmg"
162}
163
164#[cfg(not(any(target_os = "windows", target_os = "macos")))]
165fn platform_asset_suffix() -> &'static str {
166 "linux-x86_64.tar.gz"
167}
168
169#[cfg(target_os = "windows")]
170fn download_filename() -> &'static str {
171 "fastpack_update.msi"
172}
173
174#[cfg(target_os = "macos")]
175fn download_filename() -> &'static str {
176 "fastpack_update.dmg"
177}
178
179#[cfg(not(any(target_os = "windows", target_os = "macos")))]
180fn download_filename() -> &'static str {
181 "fastpack_update.tar.gz"
182}
183
184#[cfg(target_os = "windows")]
185fn do_apply(downloaded: &std::path::Path) -> Result<(), String> {
186 std::process::Command::new("msiexec")
187 .args(["/i", downloaded.to_str().unwrap_or(""), "/passive"])
188 .spawn()
189 .map_err(|e| e.to_string())?;
190 std::process::exit(0);
191}
192
193#[cfg(target_os = "macos")]
194fn do_apply(downloaded: &std::path::Path) -> Result<(), String> {
195 std::process::Command::new("open")
196 .arg(downloaded)
197 .spawn()
198 .map_err(|e| e.to_string())?;
199 Ok(())
200}
201
202#[cfg(not(any(target_os = "windows", target_os = "macos")))]
203fn do_apply(downloaded: &std::path::Path) -> Result<(), String> {
204 let current = std::env::current_exe().map_err(|e| e.to_string())?;
205 let extract_dir = std::env::temp_dir().join("fastpack_update_extract");
206 std::fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?;
207
208 let status = std::process::Command::new("tar")
209 .args([
210 "-xzf",
211 downloaded.to_str().unwrap_or(""),
212 "-C",
213 extract_dir.to_str().unwrap_or(""),
214 ])
215 .status()
216 .map_err(|e| e.to_string())?;
217 if !status.success() {
218 return Err("failed to extract update archive".to_string());
219 }
220
221 let extracted = extract_dir.join("fastpack");
222 {
223 use std::os::unix::fs::PermissionsExt;
224 let mut perms = std::fs::metadata(&extracted)
225 .map_err(|e| e.to_string())?
226 .permissions();
227 perms.set_mode(0o755);
228 std::fs::set_permissions(&extracted, perms).map_err(|e| e.to_string())?;
229 }
230 std::fs::copy(&extracted, ¤t).map_err(|e| e.to_string())?;
231 std::process::Command::new(¤t)
232 .spawn()
233 .map_err(|e| e.to_string())?;
234 std::process::exit(0);
235}