use crate::model::settings;
use anyhow::{Context as _, Result, bail};
use bezel::gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
use serde::Deserialize;
use std::{
ffi::OsStr,
path::{Path, PathBuf},
process::{Command, Output},
time::Duration,
};
pub const FEED: &str = concat!(env!("CARGO_PKG_HOMEPAGE"), "/changelog.json");
const VERSION: &str = env!("CARGO_PKG_VERSION");
const REPO: &str = env!("CARGO_PKG_REPOSITORY");
const IMAGE: &str = "cydonia-";
const STAGING: &str = ".cydonia-update-";
const ARCH: &str = "arm64";
const SUPPORTED: bool = cfg!(all(target_os = "macos", target_arch = "aarch64"));
const FIRST: Duration = Duration::from_secs(30);
const EVERY: Duration = Duration::from_secs(6 * 60 * 60);
const FEED_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone)]
pub enum Status {
Idle,
Checking,
Current,
Available(SharedString),
Downloading(SharedString),
Ready {
version: SharedString,
staged: PathBuf,
},
Failed(SharedString),
}
#[derive(Deserialize)]
struct Entry {
version: String,
}
pub struct Updater {
status: Status,
app: Option<PathBuf>,
poll: Option<Task<()>>,
preview: bool,
}
struct Handle(Entity<Updater>);
impl Global for Handle {}
pub fn init(auto: bool, cx: &mut App) {
let app = bundle(cx);
if let Some(app) = app.as_deref() {
sweep(app);
}
let updater = cx.new(|cx| {
let mut this = Updater {
status: Status::Idle,
app,
poll: None,
preview: false,
};
if auto {
this.poll(cx);
}
this
});
cx.set_global(Handle(updater));
}
pub fn of(cx: &App) -> Option<Entity<Updater>> {
Some(cx.try_global::<Handle>()?.0.clone())
}
pub fn supported(cx: &App) -> bool {
cx.try_global::<Handle>()
.is_some_and(|handle| handle.0.read(cx).app.is_some())
}
impl Updater {
pub fn status(&self) -> &Status {
&self.status
}
pub fn ready(&self) -> Option<SharedString> {
if self.preview {
return Some(pretend());
}
match &self.status {
Status::Ready { version, .. } => Some(version.clone()),
_ => None,
}
}
pub fn previewing(&self) -> bool {
self.preview
}
pub fn set_preview(&mut self, on: bool, cx: &mut Context<Self>) {
self.preview = on;
cx.notify();
}
pub fn check(&mut self, manual: bool, cx: &mut Context<Self>) {
if matches!(
self.status,
Status::Checking | Status::Downloading(_) | Status::Ready { .. }
) {
return;
}
let app = self.app.clone();
self.status = Status::Checking;
cx.notify();
cx.spawn(async move |this, cx| {
let found = cx.background_executor().spawn(async { latest() }).await;
let release = match found {
Ok(Some(release)) => release,
Ok(None) => {
let _ = this.update(cx, |this, cx| this.settle(Status::Current, cx));
return;
}
Err(err) => {
let _ = this.update(cx, |this, cx| this.settle(failure(err, manual), cx));
return;
}
};
let version = SharedString::from(release.clone());
let Some(app) = app else {
let _ = this.update(cx, |this, cx| this.settle(Status::Available(version), cx));
return;
};
if this
.update(cx, |this, cx| {
this.settle(Status::Downloading(version.clone()), cx)
})
.is_err()
{
return;
}
let staged = cx
.background_executor()
.spawn(async move { stage(&release, &app) })
.await;
let _ = this.update(cx, |this, cx| {
let status = match staged {
Ok(staged) => Status::Ready { version, staged },
Err(err) => failure(err, manual),
};
this.settle(status, cx);
});
})
.detach();
}
pub fn restart(&mut self, cx: &mut Context<Self>) {
let handed = {
let (Status::Ready { staged, .. }, Some(app)) = (&self.status, &self.app) else {
return;
};
swap_on_exit(app, staged)
};
match handed {
Ok(()) => cx.quit(),
Err(err) => self.settle(Status::Failed(format!("{err:#}").into()), cx),
}
}
pub fn set_auto(&mut self, on: bool, cx: &mut Context<Self>) {
match (on, self.poll.is_some()) {
(true, false) => self.poll(cx),
(false, true) => self.poll = None,
_ => {}
}
}
fn poll(&mut self, cx: &mut Context<Self>) {
self.poll = Some(cx.spawn(async move |this, cx| {
let mut delay = FIRST;
loop {
cx.background_executor().timer(delay).await;
delay = EVERY;
let looking = this.update(cx, |this, cx| {
this.check(false, cx);
!matches!(this.status, Status::Ready { .. } | Status::Available(_))
});
if !matches!(looking, Ok(true)) {
return;
}
}
}));
}
fn settle(&mut self, status: Status, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
}
fn bundle(cx: &App) -> Option<PathBuf> {
if !SUPPORTED {
return None;
}
let path = cx.app_path().ok()?;
(path.extension()? == "app").then_some(path)
}
fn latest() -> Result<Option<String>> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(FEED_TIMEOUT))
.build()
.new_agent();
let body = agent
.get(FEED)
.call()
.context("the release feed could not be read")?
.body_mut()
.read_to_string()?;
let version = head(&body)?;
Ok(newer(&version, VERSION).then_some(version))
}
pub fn head(feed: &str) -> Result<String> {
let entries: Vec<Entry> =
serde_json::from_str(feed).context("the release feed is not the shape this build reads")?;
let newest = entries
.into_iter()
.next()
.context("the release feed is empty")?;
Ok(newest.version)
}
pub fn newer(offered: &str, running: &str) -> bool {
match (numbers(offered), numbers(running)) {
(Some(offered), Some(running)) => offered > running,
_ => false,
}
}
fn numbers(version: &str) -> Option<[u64; 3]> {
let mut parts = version.split('.');
let mut out = [0; 3];
for slot in &mut out {
*slot = parts.next()?.parse().ok()?;
}
parts.next().is_none().then_some(out)
}
fn pretend() -> SharedString {
match numbers(VERSION) {
Some([major, minor, patch]) => format!("{major}.{minor}.{}", patch + 1).into(),
None => VERSION.into(),
}
}
pub fn asset(version: &str) -> String {
format!("{IMAGE}{version}-{ARCH}.dmg")
}
pub fn version_of(image: &Path) -> Option<String> {
let name = image.file_name()?.to_str()?;
Some(
name.strip_prefix(IMAGE)?
.strip_suffix(&format!("-{ARCH}.dmg"))?
.to_owned(),
)
}
pub fn url(version: &str) -> String {
format!("{REPO}/releases/download/v{version}/{}", asset(version))
}
fn stage(version: &str, app: &Path) -> Result<PathBuf> {
let parent = app.parent().context("the app is at the root of a volume")?;
let staging = parent.join(format!("{STAGING}{version}"));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging)
.with_context(|| format!("{} cannot be written to", parent.display()))?;
let image = download(version)?;
let mount = cache()?.join("mount");
let _ = std::fs::create_dir_all(&mount);
let _ = run(
"/usr/bin/hdiutil",
[
OsStr::new("detach"),
mount.as_os_str(),
OsStr::new("-quiet"),
],
);
run(
"/usr/bin/hdiutil",
[
OsStr::new("attach"),
image.as_os_str(),
OsStr::new("-nobrowse"),
OsStr::new("-readonly"),
OsStr::new("-noautoopen"),
OsStr::new("-mountpoint"),
mount.as_os_str(),
],
)?;
let staged = copy_out(&mount, &staging);
let _ = run(
"/usr/bin/hdiutil",
[
OsStr::new("detach"),
mount.as_os_str(),
OsStr::new("-quiet"),
],
);
let staged = staged?;
match verify(&staged, app) {
Ok(()) => Ok(staged),
Err(err) => {
let _ = std::fs::remove_dir_all(&staging);
Err(err)
}
}
}
fn copy_out(mount: &Path, staging: &Path) -> Result<PathBuf> {
let bundle = std::fs::read_dir(mount)?
.flatten()
.map(|entry| entry.path())
.find(|path| path.extension().is_some_and(|ext| ext == "app"))
.context("the image holds no app")?;
let staged = staging.join(bundle.file_name().context("the app has no name")?);
run("/usr/bin/ditto", [bundle.as_os_str(), staged.as_os_str()])?;
Ok(staged)
}
fn verify(staged: &Path, running: &Path) -> Result<()> {
run(
"/usr/bin/codesign",
[
OsStr::new("--verify"),
OsStr::new("--strict"),
staged.as_os_str(),
],
)
.context("the release does not match its own signature")?;
run(
"/usr/sbin/spctl",
[
OsStr::new("--assess"),
OsStr::new("--type"),
OsStr::new("exec"),
staged.as_os_str(),
],
)
.context("the release is not notarized")?;
if team(staged)? != team(running)? {
bail!("the release is signed by another developer than this copy");
}
Ok(())
}
fn team(bundle: &Path) -> Result<Option<String>> {
let out = run(
"/usr/bin/codesign",
[
OsStr::new("-d"),
OsStr::new("--verbose=4"),
bundle.as_os_str(),
],
)?;
Ok(String::from_utf8_lossy(&out.stderr)
.lines()
.find_map(|line| line.strip_prefix("TeamIdentifier="))
.filter(|team| *team != "not set")
.map(str::to_owned))
}
fn download(version: &str) -> Result<PathBuf> {
let dir = downloads()?;
std::fs::create_dir_all(&dir)?;
let image = dir.join(asset(version));
if image.is_file() {
return Ok(image);
}
let part = image.with_extension("part");
let fetched = (|| -> Result<()> {
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.build()
.new_agent();
let mut body = agent.get(url(version)).call()?;
let mut file = std::fs::File::create(&part)?;
std::io::copy(&mut body.body_mut().as_reader(), &mut file)?;
file.sync_all()?;
Ok(())
})();
if fetched.is_err() {
let _ = std::fs::remove_file(&part);
}
fetched.with_context(|| format!("cydonia {version} could not be downloaded"))?;
std::fs::rename(&part, &image)?;
Ok(image)
}
fn sweep(app: &Path) {
if let Ok(dir) = downloads()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for path in entries.flatten().map(|entry| entry.path()) {
if version_of(&path).is_none_or(|version| !newer(&version, VERSION)) {
let _ = std::fs::remove_file(&path);
}
}
}
if let Some(parent) = app.parent()
&& let Ok(entries) = std::fs::read_dir(parent)
{
for path in entries.flatten().map(|entry| entry.path()) {
let ours = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(spent);
if ours {
let _ = std::fs::remove_dir_all(&path);
}
}
}
}
pub fn spent(name: &str) -> bool {
name.strip_prefix(STAGING)
.is_some_and(|version| !newer(version, VERSION))
}
fn cache() -> Result<PathBuf> {
Ok(settings::dir()?.join("cache"))
}
fn downloads() -> Result<PathBuf> {
Ok(cache()?.join("updates"))
}
const SWAP: &str = r#"
while kill -0 $0 2> /dev/null; do
sleep 0.1
done
app="$1"
staged="$2"
old="$app.old"
rm -rf "$old"
if mv "$app" "$old"; then
if mv "$staged" "$app"; then
rm -rf "$old"
rmdir "$(dirname "$staged")" 2> /dev/null
else
mv "$old" "$app"
fi
fi
open "$app"
"#;
fn swap_on_exit(app: &Path, staged: &Path) -> Result<()> {
let mut command = Command::new("/bin/bash");
command
.arg("-c")
.arg(SWAP)
.arg(std::process::id().to_string())
.arg(app)
.arg(staged);
detach(&mut command);
command.spawn().context("the update script did not start")?;
Ok(())
}
#[cfg(unix)]
fn detach(command: &mut Command) {
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
#[cfg(not(unix))]
fn detach(_: &mut Command) {}
fn run<'a>(program: &str, args: impl IntoIterator<Item = &'a OsStr>) -> Result<Output> {
let out = Command::new(program)
.args(args)
.output()
.with_context(|| format!("{program} could not be run"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let line = stderr.lines().find(|line| !line.trim().is_empty());
bail!("{program}: {}", line.unwrap_or("failed").trim());
}
Ok(out)
}
fn failure(err: anyhow::Error, manual: bool) -> Status {
if manual {
Status::Failed(format!("{err:#}").into())
} else {
Status::Idle
}
}