use crate::model::settings;
#[cfg(unix)]
use anyhow::bail;
use anyhow::{Context as _, Result};
use bezel::gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
use serde::Deserialize;
#[cfg(unix)]
use std::{
ffi::OsStr,
process::{Command, Output},
};
use std::{
path::{Path, PathBuf},
time::Duration,
};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(windows)]
mod windows;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
#[cfg(windows)]
use windows as platform;
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
mod platform {
use anyhow::{Result, bail};
use bezel::gpui::App;
use std::path::{Path, PathBuf};
pub(super) const SUPPORTED: bool = false;
pub(super) const SUFFIX: &str = "";
pub(super) fn remote(_: &str) -> String {
String::new()
}
pub(super) fn bundle(_: &App) -> Option<PathBuf> {
None
}
pub(super) fn stage(_: &str, _: &Path) -> Result<PathBuf> {
bail!("no release is cut for this platform")
}
pub(super) fn swap_on_exit(_: &Path, _: &Path) -> Result<()> {
bail!("no release is cut for this platform")
}
}
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 FIRST: Duration = Duration::from_secs(3);
const EVERY: Duration = Duration::from_secs(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 = platform::SUPPORTED.then(|| platform::bundle(cx)).flatten();
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 { platform::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;
};
platform::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();
}
}
pub fn asset(version: &str) -> String {
format!("{IMAGE}{version}{}", platform::SUFFIX)
}
pub fn version_of(image: &Path) -> Option<String> {
let name = image.file_name()?.to_str()?;
Some(
name.strip_prefix(IMAGE)?
.strip_suffix(platform::SUFFIX)?
.to_owned(),
)
.filter(|version| !version.is_empty())
}
pub fn url(version: &str) -> String {
format!(
"{REPO}/releases/download/v{version}/{}",
platform::remote(version)
)
}
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(),
}
}
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"))
}
#[cfg(unix)]
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
"#;
#[cfg(unix)]
fn swap_on_exit(app: &Path, staged: &Path, launch: &str) -> Result<()> {
use std::os::unix::process::CommandExt as _;
let mut command = Command::new("/bin/sh");
command
.arg("-c")
.arg(format!("{SWAP} {launch}\n"))
.arg(std::process::id().to_string())
.arg(app)
.arg(staged)
.process_group(0);
command.spawn().context("the update script did not start")?;
Ok(())
}
#[cfg(unix)]
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
}
}