Skip to main content

fastpack_gui/
updater.rs

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    /// Release notes text from GitHub.
11    pub notes: String,
12    /// Direct download URL for the platform-specific binary.
13    pub asset_url: String,
14}
15
16/// Messages sent from the update background thread to the UI.
17pub enum UpdateMsg {
18    /// Current version is the latest.
19    UpToDate { latest: String },
20    /// A newer release is available.
21    Available(ReleaseInfo),
22    /// The update binary was downloaded to the given path.
23    Downloaded(PathBuf),
24    /// The update check or download failed.
25    Error(String),
26}
27
28/// Current state of the update check shown in the preferences window.
29pub enum UpdateStatus {
30    /// No check has been started.
31    Idle,
32    /// A check is running in the background.
33    Checking,
34    /// Check completed; running version is the latest.
35    UpToDate { latest: String },
36    /// A newer release was found.
37    Available(ReleaseInfo),
38    /// The update binary is being downloaded.
39    Downloading,
40    /// Download finished; binary is at the given path.
41    Downloaded(PathBuf),
42    /// The check or download failed with this message.
43    Error(String),
44}
45
46/// Spawn a background thread to check GitHub for the latest release.
47pub 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
70/// Spawn a background thread to download the release asset.
71pub 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
81/// Replace the running binary with the downloaded update file and restart.
82pub 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, &current).map_err(|e| e.to_string())?;
231    std::process::Command::new(&current)
232        .spawn()
233        .map_err(|e| e.to_string())?;
234    std::process::exit(0);
235}