1use std::rc::Rc;
11use std::time::Duration;
12
13use gpui::{App, SharedString};
14
15use super::{InstallKind, Relaunch, Release, UpdateCheck, UpdateSource, UpdateStage};
16
17pub const POLL: Duration = Duration::from_secs(60 * 60);
19
20#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct UpdateConfig {
25 pub(crate) app: String,
27 pub(crate) slug: String,
29 pub(crate) version: String,
31 pub(crate) source: UpdateSource,
33 pub(crate) user_agent: String,
35 pub(crate) requirement: Option<String>,
37 pub(crate) require_checksum: bool,
40}
41
42impl UpdateConfig {
43 pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
45 let app = app.into();
46 let slug = slug(&app);
47 let user_agent = format!("{slug}-updater");
48 UpdateConfig {
49 app,
50 slug,
51 version: version.into(),
52 source,
53 user_agent,
54 requirement: None,
55 require_checksum: false,
56 }
57 }
58
59 pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
75 self.requirement = Some(requirement.into());
76 self
77 }
78
79 pub fn require_checksum(mut self, require: bool) -> Self {
90 self.require_checksum = require;
91 self
92 }
93
94 pub fn requires_checksum(&self) -> bool {
96 self.require_checksum
97 }
98
99 pub(crate) fn verify_checksum(
106 &self,
107 release: &Release,
108 asset: &super::ReleaseAsset,
109 file: &std::path::Path,
110 ) -> Result<(), String> {
111 let Some(published) = release.checksum_for(asset) else {
112 if self.require_checksum {
113 return Err(format!(
114 "this release publishes no SHA-256 for {} — refusing to install it",
115 asset.name
116 ));
117 }
118 return Ok(());
119 };
120 let body = super::fetch::bytes(&published.url, &self.user_agent)
121 .map_err(|e| format!("could not fetch the published checksum: {e}"))?;
122 let body = String::from_utf8_lossy(&body);
123 let expected = super::checksum::find(&body, &asset.name).ok_or_else(|| {
124 format!(
125 "{} does not record a SHA-256 for {}",
126 published.name, asset.name
127 )
128 })?;
129 let actual = super::checksum::of_file(file)?;
130 if !super::checksum::matches(&expected, &actual) {
131 return Err(format!(
132 "{} does not match its published SHA-256 — refusing to install it",
133 asset.name
134 ));
135 }
136 Ok(())
137 }
138
139 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
141 self.user_agent = user_agent.into();
142 self
143 }
144
145 pub fn slug(mut self, slug: impl Into<String>) -> Self {
147 self.slug = slug.into();
148 self
149 }
150
151 pub fn app(&self) -> &str {
153 &self.app
154 }
155
156 pub fn version(&self) -> &str {
158 &self.version
159 }
160
161 pub fn source(&self) -> &UpdateSource {
163 &self.source
164 }
165
166 pub fn install_kind(&self) -> InstallKind {
168 super::detect()
169 }
170
171 pub fn check(&self) -> Result<UpdateCheck, String> {
175 super::release::check(
176 &self.source,
177 &self.user_agent,
178 &self.version,
179 &self.install_kind(),
180 )
181 }
182
183 pub fn install(
188 &self,
189 release: &Release,
190 kind: &InstallKind,
191 on_stage: &dyn Fn(UpdateStage),
192 ) -> Result<Relaunch, String> {
193 match kind {
194 InstallKind::MacApp(app) => super::mac::install(self, release, app, on_stage),
195 InstallKind::AppImage(path) => super::appimage::install(self, release, path, on_stage),
196 InstallKind::Unknown => Err("this install can't be updated in place".to_string()),
197 }
198 }
199
200 pub fn can_install(&self, release: &Release, kind: &InstallKind) -> bool {
209 if !kind.is_in_place() || release.asset_for(kind).is_none() {
210 return false;
211 }
212 !matches!(kind, InstallKind::MacApp(_)) || self.requirement.is_some()
213 }
214}
215
216#[derive(Clone)]
227pub struct Updater {
228 config: UpdateConfig,
229 poll: Duration,
230 title: SharedString,
231 notify: Option<NotifyHook>,
232 before_restart: Option<RestartHook>,
233}
234
235type NotifyHook = Rc<dyn Fn(&str, &str)>;
237
238type RestartHook = Rc<dyn Fn(&mut App)>;
240
241impl Updater {
242 pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
244 Updater::from_config(UpdateConfig::new(app, version, source))
245 }
246
247 pub fn github(
249 app: impl Into<String>,
250 version: impl Into<String>,
251 repo: impl Into<String>,
252 ) -> Self {
253 Updater::new(app, version, UpdateSource::github(repo))
254 }
255
256 pub fn from_config(config: UpdateConfig) -> Self {
258 Updater {
259 config,
260 poll: POLL,
261 title: "Software Update".into(),
262 notify: None,
263 before_restart: None,
264 }
265 }
266
267 pub fn require_checksum(mut self, require: bool) -> Self {
270 self.config = self.config.require_checksum(require);
271 self
272 }
273
274 pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
276 self.config = self.config.codesign_requirement(requirement);
277 self
278 }
279
280 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
282 self.config = self.config.user_agent(user_agent);
283 self
284 }
285
286 pub fn slug(mut self, slug: impl Into<String>) -> Self {
288 self.config = self.config.slug(slug);
289 self
290 }
291
292 pub fn poll_every(mut self, every: Duration) -> Self {
294 self.poll = every;
295 self
296 }
297
298 pub fn window_title(mut self, title: impl Into<SharedString>) -> Self {
301 self.title = title.into();
302 self
303 }
304
305 pub fn on_notify(mut self, notify: impl Fn(&str, &str) + 'static) -> Self {
309 self.notify = Some(Rc::new(notify));
310 self
311 }
312
313 pub fn before_restart(mut self, hook: impl Fn(&mut App) + 'static) -> Self {
317 self.before_restart = Some(Rc::new(hook));
318 self
319 }
320
321 pub fn config(&self) -> &UpdateConfig {
323 &self.config
324 }
325
326 pub fn app(&self) -> &str {
328 self.config.app()
329 }
330
331 pub fn version(&self) -> &str {
333 self.config.version()
334 }
335
336 pub fn poll(&self) -> Duration {
338 self.poll
339 }
340
341 pub fn title(&self) -> &SharedString {
343 &self.title
344 }
345
346 pub(crate) fn notify(&self, title: &str, body: &str) {
348 if let Some(notify) = &self.notify {
349 notify(title, body);
350 }
351 }
352
353 pub(crate) fn run_before_restart(&self, cx: &mut App) {
355 if let Some(hook) = &self.before_restart {
356 hook(cx);
357 }
358 }
359}
360
361fn slug(app: &str) -> String {
365 let mut out = String::with_capacity(app.len());
366 for ch in app.chars() {
367 if ch.is_ascii_alphanumeric() {
368 out.push(ch.to_ascii_lowercase());
369 } else if !out.ends_with('-') {
370 out.push('-');
371 }
372 }
373 let trimmed = out.trim_matches('-');
374 if trimmed.is_empty() {
375 "app".to_string()
376 } else {
377 trimmed.to_string()
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::update::ReleaseAsset;
385 use std::path::PathBuf;
386
387 fn release(names: &[&str]) -> Release {
388 Release {
389 version: "9.9.9".to_string(),
390 url: "https://acme.dev/releases/9.9.9".to_string(),
391 assets: names
392 .iter()
393 .map(|name| ReleaseAsset {
394 name: name.to_string(),
395 url: format!("https://d/{name}"),
396 size: 1,
397 })
398 .collect(),
399 }
400 }
401
402 fn config() -> UpdateConfig {
403 UpdateConfig::new("Acme", "1.0.0", UpdateSource::github("acme/acme"))
404 }
405
406 #[test]
407 fn slugs_are_path_safe() {
408 assert_eq!(slug("Acme"), "acme");
409 assert_eq!(slug("My App 2.0"), "my-app-2-0");
410 assert_eq!(slug(" Spaced "), "spaced");
411 assert_eq!(slug("../../etc"), "etc");
412 assert_eq!(slug("🚀"), "app");
413 assert_eq!(slug(""), "app");
414 }
415
416 #[test]
417 fn the_user_agent_defaults_to_the_slug() {
418 assert_eq!(config().user_agent, "acme-updater");
419 assert_eq!(
420 UpdateConfig::new("My App", "1", UpdateSource::github("a/b")).user_agent,
421 "my-app-updater"
422 );
423 }
424
425 #[test]
428 fn macos_needs_a_codesign_requirement_to_be_installable() {
429 let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
430 let dmg = release(&["Acme.dmg"]);
431 assert!(!config().can_install(&dmg, &mac));
432 assert!(config()
433 .codesign_requirement("anchor apple generic")
434 .can_install(&dmg, &mac));
435 }
436
437 #[test]
440 fn appimage_is_installable_without_a_requirement() {
441 let image = InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"));
442 let asset = format!("Acme-9.9.9-{}.AppImage", std::env::consts::ARCH);
443 assert!(config().can_install(&release(&[&asset]), &image));
444 }
445
446 #[test]
447 fn a_release_without_our_asset_is_not_installable() {
448 let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
449 let config = config().codesign_requirement("anchor apple generic");
450 assert!(!config.can_install(&release(&["Acme.AppImage"]), &mac));
451 assert!(!config.can_install(&release(&[]), &mac));
452 }
453
454 #[test]
455 fn unknown_installs_are_never_installable_in_place() {
456 let config = config().codesign_requirement("anchor apple generic");
457 let every_asset = release(&["Acme.dmg", "Acme.AppImage"]);
458 assert!(!config.can_install(&every_asset, &InstallKind::Unknown));
459 assert!(config
460 .install(&every_asset, &InstallKind::Unknown, &|_| {})
461 .is_err());
462 }
463
464 #[test]
467 fn require_checksum_refuses_a_release_with_no_digest() {
468 let release = crate::update::Release {
469 version: "9.9.9".to_string(),
470 url: String::new(),
471 assets: vec![crate::update::ReleaseAsset {
472 name: "Acme.AppImage".to_string(),
473 url: "https://d/a".to_string(),
474 size: 1,
475 }],
476 };
477 let asset = release.assets[0].clone();
478 let path = std::path::Path::new("/nonexistent/Acme.AppImage");
479
480 let lenient = config();
482 assert!(lenient.verify_checksum(&release, &asset, path).is_ok());
483
484 let strict = config().require_checksum(true);
485 assert!(strict.requires_checksum());
486 let err = strict
487 .verify_checksum(&release, &asset, path)
488 .expect_err("a missing digest must block the install");
489 assert!(err.contains("publishes no SHA-256"), "{err}");
490 }
491}