use std::rc::Rc;
use std::time::Duration;
use gpui::{App, SharedString};
use super::{InstallKind, Relaunch, Release, UpdateCheck, UpdateSource, UpdateStage};
pub const POLL: Duration = Duration::from_secs(60 * 60);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateConfig {
pub(crate) app: String,
pub(crate) slug: String,
pub(crate) version: String,
pub(crate) source: UpdateSource,
pub(crate) user_agent: String,
pub(crate) requirement: Option<String>,
pub(crate) require_checksum: bool,
}
impl UpdateConfig {
pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
let app = app.into();
let slug = slug(&app);
let user_agent = format!("{slug}-updater");
UpdateConfig {
app,
slug,
version: version.into(),
source,
user_agent,
requirement: None,
require_checksum: false,
}
}
pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
self.requirement = Some(requirement.into());
self
}
pub fn require_checksum(mut self, require: bool) -> Self {
self.require_checksum = require;
self
}
pub fn requires_checksum(&self) -> bool {
self.require_checksum
}
pub(crate) fn verify_checksum(
&self,
release: &Release,
asset: &super::ReleaseAsset,
file: &std::path::Path,
) -> Result<(), String> {
let Some(published) = release.checksum_for(asset) else {
if self.require_checksum {
return Err(format!(
"this release publishes no SHA-256 for {} — refusing to install it",
asset.name
));
}
return Ok(());
};
let body = super::fetch::bytes(&published.url, &self.user_agent)
.map_err(|e| format!("could not fetch the published checksum: {e}"))?;
let body = String::from_utf8_lossy(&body);
let expected = super::checksum::find(&body, &asset.name).ok_or_else(|| {
format!(
"{} does not record a SHA-256 for {}",
published.name, asset.name
)
})?;
let actual = super::checksum::of_file(file)?;
if !super::checksum::matches(&expected, &actual) {
return Err(format!(
"{} does not match its published SHA-256 — refusing to install it",
asset.name
));
}
Ok(())
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn slug(mut self, slug: impl Into<String>) -> Self {
self.slug = slug.into();
self
}
pub fn app(&self) -> &str {
&self.app
}
pub fn version(&self) -> &str {
&self.version
}
pub fn source(&self) -> &UpdateSource {
&self.source
}
pub fn install_kind(&self) -> InstallKind {
super::detect()
}
pub fn check(&self) -> Result<UpdateCheck, String> {
super::release::check(
&self.source,
&self.user_agent,
&self.version,
&self.install_kind(),
)
}
pub fn install(
&self,
release: &Release,
kind: &InstallKind,
on_stage: &dyn Fn(UpdateStage),
) -> Result<Relaunch, String> {
match kind {
InstallKind::MacApp(app) => super::mac::install(self, release, app, on_stage),
InstallKind::AppImage(path) => super::appimage::install(self, release, path, on_stage),
InstallKind::Unknown => Err("this install can't be updated in place".to_string()),
}
}
pub fn can_install(&self, release: &Release, kind: &InstallKind) -> bool {
if !kind.is_in_place() || release.asset_for(kind).is_none() {
return false;
}
!matches!(kind, InstallKind::MacApp(_)) || self.requirement.is_some()
}
}
#[derive(Clone)]
pub struct Updater {
config: UpdateConfig,
poll: Duration,
title: SharedString,
notify: Option<NotifyHook>,
before_restart: Option<RestartHook>,
}
type NotifyHook = Rc<dyn Fn(&str, &str)>;
type RestartHook = Rc<dyn Fn(&mut App)>;
impl Updater {
pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
Updater::from_config(UpdateConfig::new(app, version, source))
}
pub fn github(
app: impl Into<String>,
version: impl Into<String>,
repo: impl Into<String>,
) -> Self {
Updater::new(app, version, UpdateSource::github(repo))
}
pub fn from_config(config: UpdateConfig) -> Self {
Updater {
config,
poll: POLL,
title: "Software Update".into(),
notify: None,
before_restart: None,
}
}
pub fn require_checksum(mut self, require: bool) -> Self {
self.config = self.config.require_checksum(require);
self
}
pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
self.config = self.config.codesign_requirement(requirement);
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.config = self.config.user_agent(user_agent);
self
}
pub fn slug(mut self, slug: impl Into<String>) -> Self {
self.config = self.config.slug(slug);
self
}
pub fn poll_every(mut self, every: Duration) -> Self {
self.poll = every;
self
}
pub fn window_title(mut self, title: impl Into<SharedString>) -> Self {
self.title = title.into();
self
}
pub fn on_notify(mut self, notify: impl Fn(&str, &str) + 'static) -> Self {
self.notify = Some(Rc::new(notify));
self
}
pub fn before_restart(mut self, hook: impl Fn(&mut App) + 'static) -> Self {
self.before_restart = Some(Rc::new(hook));
self
}
pub fn config(&self) -> &UpdateConfig {
&self.config
}
pub fn app(&self) -> &str {
self.config.app()
}
pub fn version(&self) -> &str {
self.config.version()
}
pub fn poll(&self) -> Duration {
self.poll
}
pub fn title(&self) -> &SharedString {
&self.title
}
pub(crate) fn notify(&self, title: &str, body: &str) {
if let Some(notify) = &self.notify {
notify(title, body);
}
}
pub(crate) fn run_before_restart(&self, cx: &mut App) {
if let Some(hook) = &self.before_restart {
hook(cx);
}
}
}
fn slug(app: &str) -> String {
let mut out = String::with_capacity(app.len());
for ch in app.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"app".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update::ReleaseAsset;
use std::path::PathBuf;
fn release(names: &[&str]) -> Release {
Release {
version: "9.9.9".to_string(),
url: "https://acme.dev/releases/9.9.9".to_string(),
assets: names
.iter()
.map(|name| ReleaseAsset {
name: name.to_string(),
url: format!("https://d/{name}"),
size: 1,
})
.collect(),
}
}
fn config() -> UpdateConfig {
UpdateConfig::new("Acme", "1.0.0", UpdateSource::github("acme/acme"))
}
#[test]
fn slugs_are_path_safe() {
assert_eq!(slug("Acme"), "acme");
assert_eq!(slug("My App 2.0"), "my-app-2-0");
assert_eq!(slug(" Spaced "), "spaced");
assert_eq!(slug("../../etc"), "etc");
assert_eq!(slug("🚀"), "app");
assert_eq!(slug(""), "app");
}
#[test]
fn the_user_agent_defaults_to_the_slug() {
assert_eq!(config().user_agent, "acme-updater");
assert_eq!(
UpdateConfig::new("My App", "1", UpdateSource::github("a/b")).user_agent,
"my-app-updater"
);
}
#[test]
fn macos_needs_a_codesign_requirement_to_be_installable() {
let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
let dmg = release(&["Acme.dmg"]);
assert!(!config().can_install(&dmg, &mac));
assert!(config()
.codesign_requirement("anchor apple generic")
.can_install(&dmg, &mac));
}
#[test]
fn appimage_is_installable_without_a_requirement() {
let image = InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"));
let asset = format!("Acme-9.9.9-{}.AppImage", std::env::consts::ARCH);
assert!(config().can_install(&release(&[&asset]), &image));
}
#[test]
fn a_release_without_our_asset_is_not_installable() {
let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
let config = config().codesign_requirement("anchor apple generic");
assert!(!config.can_install(&release(&["Acme.AppImage"]), &mac));
assert!(!config.can_install(&release(&[]), &mac));
}
#[test]
fn unknown_installs_are_never_installable_in_place() {
let config = config().codesign_requirement("anchor apple generic");
let every_asset = release(&["Acme.dmg", "Acme.AppImage"]);
assert!(!config.can_install(&every_asset, &InstallKind::Unknown));
assert!(config
.install(&every_asset, &InstallKind::Unknown, &|_| {})
.is_err());
}
#[test]
fn require_checksum_refuses_a_release_with_no_digest() {
let release = crate::update::Release {
version: "9.9.9".to_string(),
url: String::new(),
assets: vec![crate::update::ReleaseAsset {
name: "Acme.AppImage".to_string(),
url: "https://d/a".to_string(),
size: 1,
}],
};
let asset = release.assets[0].clone();
let path = std::path::Path::new("/nonexistent/Acme.AppImage");
let lenient = config();
assert!(lenient.verify_checksum(&release, &asset, path).is_ok());
let strict = config().require_checksum(true);
assert!(strict.requires_checksum());
let err = strict
.verify_checksum(&release, &asset, path)
.expect_err("a missing digest must block the install");
assert!(err.contains("publishes no SHA-256"), "{err}");
}
}