use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;
const ENDPOINT: &str =
"https://github.com/project-oxi/oximemo/releases/latest/download/latest.json";
const REPO: &str = "project-oxi/oximemo";
const TARGET_TRIPLE: &str = "aarch64-apple-darwin";
const PLATFORM_KEY: &str = "darwin-aarch64";
const PUBKEY: &str = "RWSPFXSR74pl0b+Ssow4gaUe7zr3ftkFG2S1obIcjfFKumljMGOYxgqq";
#[derive(Deserialize)]
struct Manifest {
version: String,
#[serde(default)]
platforms: HashMap<String, PlatformAsset>,
}
#[derive(Deserialize)]
struct PlatformAsset {
url: String,
signature: String,
}
pub fn run(check_only: bool) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
println!("Checking for updates…");
let manifest = fetch_manifest().context("could not check for updates")?;
let latest = manifest.version.as_str();
if !is_newer(latest, current) {
println!("Already up to date (v{current}).");
return Ok(());
}
println!("Update available: v{current} → v{latest}.");
if check_only {
return Ok(());
}
if let Some(app) = app_bundle_root() {
upgrade_in_app(&manifest, &app)?;
} else {
upgrade_standalone(latest)?;
}
Ok(())
}
fn fetch_manifest() -> Result<Manifest> {
let resp = ureq::get(ENDPOINT)
.call()
.map_err(|e| anyhow!("fetch manifest: {e}"))?;
let body = resp
.into_string()
.map_err(|e| anyhow!("read manifest: {e}"))?;
serde_json::from_str(&body).map_err(|e| anyhow!("parse manifest: {e}"))
}
fn is_newer(latest: &str, current: &str) -> bool {
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => l > c,
_ => false,
}
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let v = v.strip_prefix('v').unwrap_or(v);
let mut it = v.split('.');
let maj = it.next()?.parse().ok()?;
let min = it.next()?.parse().ok()?;
let patch_raw = it.next().unwrap_or("0");
let patch = patch_raw.split('-').next()?.parse().ok()?;
Some((maj, min, patch))
}
fn app_bundle_root() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| app_bundle_root_of(&exe))
}
fn app_bundle_root_of(exe: &Path) -> Option<PathBuf> {
for ancestor in exe.ancestors() {
if ancestor.extension().is_some_and(|e| e == "app") {
return Some(ancestor.to_path_buf());
}
}
None
}
fn upgrade_in_app(manifest: &Manifest, app: &Path) -> Result<()> {
let asset = manifest
.platforms
.get(PLATFORM_KEY)
.ok_or_else(|| anyhow!("manifest has no asset for {PLATFORM_KEY}"))?;
let parent = app
.parent()
.ok_or_else(|| anyhow!("app bundle has no parent directory"))?;
let work = sibling_tempdir(parent)?;
let result = (|| -> Result<()> {
let archive = work.join("bundle.app.tar.gz");
println!("Downloading {}…", filename_of(&asset.url));
download(&asset.url, &archive)?;
let data = fs::read(&archive)?;
println!("Verifying signature…");
verify_minisign(&data, &asset.signature)?;
println!("Installing…");
extract_tar_gz(&archive, &work)?;
let new_app = find_entry_with_ext(&work, "app")?;
let old = work.join(".previous.app");
if app.exists() {
fs::rename(app, &old)
.with_context(|| format!("move aside {}", app.display()))?;
}
fs::rename(&new_app, app)
.with_context(|| format!("install {}", app.display()))?;
let _ = fs::remove_dir_all(&old);
println!(
"Updated to v{}. Restart OxiMemo to use the new version.",
manifest.version
);
Ok(())
})();
let _ = fs::remove_dir_all(&work);
result
}
fn upgrade_standalone(latest: &str) -> Result<()> {
let exe = std::env::current_exe().context("resolve running binary")?;
let parent = exe
.parent()
.ok_or_else(|| anyhow!("binary has no parent directory"))?;
let work = sibling_tempdir(parent)?;
let result = (|| -> Result<()> {
let name = format!("oximemo-{TARGET_TRIPLE}.tar.gz");
let base = format!("https://github.com/{REPO}/releases/download/v{latest}");
let url = format!("{base}/{name}");
let archive = work.join(&name);
println!("Downloading {name}…");
download(&url, &archive)?;
let expected = download_text(&format!("{url}.sha256"))?;
let expected = expected.split_whitespace().next().unwrap_or("");
println!("Verifying checksum…");
let actual = sha256_hex(&archive)?;
if !expected.eq_ignore_ascii_case(&actual) {
bail!("checksum mismatch: expected {expected}, got {actual}");
}
println!("Installing…");
extract_tar_gz(&archive, &work)?;
let new_bin = work.join("oximemo");
set_executable(&new_bin)?;
let old = work.join(".previous.bin");
fs::rename(&exe, &old)
.with_context(|| format!("move aside {}", exe.display()))?;
fs::rename(&new_bin, &exe)
.with_context(|| format!("install {}", exe.display()))?;
let _ = fs::remove_file(&old);
println!("Updated to v{latest}.");
Ok(())
})();
let _ = fs::remove_dir_all(&work);
result
}
fn sibling_tempdir(sibling_of: &Path) -> Result<PathBuf> {
let dir = sibling_of.join(format!(".oximemo-upgrade-{}", std::process::id()));
fs::create_dir_all(&dir)
.with_context(|| format!("create work dir {}", dir.display()))?;
Ok(dir)
}
fn download(url: &str, dest: &Path) -> Result<()> {
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow!("download {url}: {e}"))?;
let mut reader = resp.into_reader();
let mut f = fs::File::create(dest)
.with_context(|| format!("create {}", dest.display()))?;
std::io::copy(&mut reader, &mut f)?;
f.sync_all().ok();
Ok(())
}
fn download_text(url: &str) -> Result<String> {
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow!("download {url}: {e}"))?;
resp.into_string()
.map_err(|e| anyhow!("read {url}: {e}"))
}
fn verify_minisign(data: &[u8], signature_b64: &str) -> Result<()> {
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD
.decode(signature_b64.trim().as_bytes())
.map_err(|e| anyhow!("decode signature: {e}"))?;
let sig_box = String::from_utf8(raw).context("signature is not valid utf-8")?;
let pk = minisign_verify::PublicKey::from_base64(PUBKEY)
.map_err(|e| anyhow!("parse public key: {e}"))?;
let sig = minisign_verify::Signature::decode(&sig_box)
.map_err(|e| anyhow!("parse signature: {e}"))?;
pk.verify(data, &sig, false)
.map_err(|e| anyhow!("signature verification failed: {e}"))
}
fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<()> {
let f = fs::File::open(archive)
.with_context(|| format!("open {}", archive.display()))?;
let gz = flate2::read::GzDecoder::new(f);
let mut tar = tar::Archive::new(gz);
tar.set_overwrite(true);
tar.unpack(dest)
.with_context(|| format!("extract {}", archive.display()))?;
Ok(())
}
fn sha256_hex(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut f = fs::File::open(path)
.with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = f.read(&mut buf).context("read for hashing")?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(path)?.permissions();
perm.set_mode(0o755);
fs::set_permissions(path, perm)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn find_entry_with_ext(dir: &Path, ext: &str) -> Result<PathBuf> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == ext) {
return Ok(entry.path());
}
}
bail!("extracted archive contained no .{ext}");
}
fn filename_of(url: &str) -> &str {
url.rsplit('/').next().unwrap_or(url)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn parses_plain_versions() {
assert_eq!(parse_version("0.9.0"), Some((0, 9, 0)));
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("1.2.3-rc1"), Some((1, 2, 3)));
}
#[test]
fn rejects_garbage_versions() {
assert_eq!(parse_version("latest"), None);
assert_eq!(parse_version(""), None);
}
#[test]
fn newer_detection() {
assert!(is_newer("0.9.1", "0.9.0"));
assert!(is_newer("1.0.0", "0.9.9")); assert!(!is_newer("0.9.0", "0.9.0"));
assert!(!is_newer("0.8.9", "0.9.0"));
}
#[test]
#[ignore]
fn verifies_live_release_signature() {
let manifest = fetch_manifest().expect("fetch manifest");
let asset = manifest
.platforms
.get(PLATFORM_KEY)
.expect("manifest has darwin-aarch64 asset");
let resp = ureq::get(&asset.url).call().expect("download bundle");
let mut data = Vec::new();
resp.into_reader()
.read_to_end(&mut data)
.expect("read bundle");
verify_minisign(&data, &asset.signature)
.expect("PUBKEY verifies the live Tauri signature");
}
#[test]
fn unparseable_never_newer() {
assert!(!is_newer("oops", "0.9.0"));
assert!(!is_newer("0.9.1", "oops"));
}
#[test]
fn detects_app_bundle_from_sidecar_path() {
let exe = PathBuf::from("/Applications/OxiMemo.app/Contents/MacOS/oximemo");
assert_eq!(
app_bundle_root_of(&exe),
Some(PathBuf::from("/Applications/OxiMemo.app"))
);
}
#[test]
fn no_app_bundle_for_standalone() {
let exe = PathBuf::from("/Users/x/.cargo/bin/oximemo");
assert_eq!(app_bundle_root_of(&exe), None);
let exe = PathBuf::from("/usr/local/bin/oximemo");
assert_eq!(app_bundle_root_of(&exe), None);
}
#[test]
fn filename_of_strips_query_and_path() {
assert_eq!(
filename_of("https://x/y/OxiMemo.app.tar.gz"),
"OxiMemo.app.tar.gz"
);
}
}