Replaces the signed tauri-plugin-updater (latest.json manifest, minisign key pair) with the same simple mechanism Sabiá already uses: query the Gitea Releases API directly, download the matching platform archive, verify its SHA-256 checksum, and swap the binary in place. The CI job that built the old latest.json manifest is removed in the next commit, alongside an unrelated runner-routing change to the same workflow file. - New guara-core::update module (check/apply), ported from sabia-core::update and adapted to Guará's own release asset naming (guara-<tag>-<platform>.tar.gz, matching scripts/package-cli.sh). - New Engine::update_check / Engine::update_apply, wired into the RPC service for the browser UI. - New CLI subcommand: "guara update" (check) and "guara update --apply" (download, verify, replace), mirroring "sabia update". - GUI: replaced the Tauri plugin-updater flow (download_and_install, update-progress events, in-app restart) with a call to the new update_check/update_apply commands. The desktop app can no longer self-install in one click — it now points the user to the Releases page, same as Sabiá's desktop app already does. - Removed tauri-plugin-updater, createUpdaterArtifacts and the updater.pubkey config, the signing-key loading in build-gui.sh, the CI step testing the old latest.json generator, and the now-unused make-latest-json.py/test_make_latest_json.py scripts. - Documented the break for existing installs in CHANGELOG.md / CHANGELOG.pt-BR.md: versions up to 1.3.2 won't find this release automatically and need one manual download. Trade-off accepted knowingly: SHA-256 verifies integrity against Gitea's own API but, unlike the retired signed mechanism, does not protect against a compromised Gitea server independently of it. This is Lucas's own self-hosted instance, not a third-party mirror. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
238 lines
7.7 KiB
Rust
238 lines
7.7 KiB
Rust
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
|
|
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
|
|
//! running `guara` binary.
|
|
//!
|
|
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
|
|
//! into `guara-core`.
|
|
|
|
use crate::error::{Error, Result};
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
|
const LATEST_RELEASE_URL: &str =
|
|
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/guara/releases/latest";
|
|
|
|
/// Human-facing Gitea Releases page for desktop GUI users.
|
|
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Release {
|
|
tag_name: String,
|
|
assets: Vec<Asset>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Asset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct UpdateStatus {
|
|
pub current_version: String,
|
|
pub latest_version: Option<String>,
|
|
pub update_available: bool,
|
|
}
|
|
|
|
fn curl_text(url: &str) -> Result<String> {
|
|
let out = Command::new("curl")
|
|
.args(["-fsSL", "-H", "User-Agent: guara-updater", url])
|
|
.output()
|
|
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
|
if !out.status.success() {
|
|
return Err(Error::msg(format!(
|
|
"curl {url}: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
)));
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
|
let status = Command::new("curl")
|
|
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
|
.arg(dest)
|
|
.arg(url)
|
|
.status()
|
|
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
|
if !status.success() {
|
|
return Err(Error::msg(format!("curl {url}: download falhou")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the expected release asset suffix for the current OS/architecture
|
|
/// (e.g. `"-linux-x86_64.tar.gz"`).
|
|
fn asset_suffix_for_platform() -> String {
|
|
let os = if cfg!(target_os = "linux") {
|
|
"linux"
|
|
} else if cfg!(target_os = "macos") {
|
|
"macos"
|
|
} else {
|
|
"windows"
|
|
};
|
|
let arch = if cfg!(target_arch = "x86_64") {
|
|
"x86_64"
|
|
} else {
|
|
"aarch64"
|
|
};
|
|
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
|
|
format!("-{os}-{arch}.{ext}")
|
|
}
|
|
|
|
/// Queries the latest published release and compares it against the running
|
|
/// `CARGO_PKG_VERSION`.
|
|
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
|
let url = LATEST_RELEASE_URL;
|
|
let body = match curl_text(url) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
// Offline or no published releases yet: treat as no update available.
|
|
return Ok(UpdateStatus {
|
|
current_version: current_version.to_string(),
|
|
latest_version: None,
|
|
update_available: false,
|
|
});
|
|
}
|
|
};
|
|
let release: Release = serde_json::from_str(&body)?;
|
|
let latest = release.tag_name.trim_start_matches('v').to_string();
|
|
let update_available = latest != current_version;
|
|
Ok(UpdateStatus {
|
|
current_version: current_version.to_string(),
|
|
latest_version: Some(latest),
|
|
update_available,
|
|
})
|
|
}
|
|
|
|
/// Downloads, verifies, and installs the latest CLI release in place.
|
|
/// Returns the newly installed version string.
|
|
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
|
// Guard against replacing `guara-gui` with the CLI binary (`guara`);
|
|
// desktop GUI updates use the installer/package from the release page instead.
|
|
let is_cli = current_exe
|
|
.file_stem()
|
|
.map(|stem| stem == "guara")
|
|
.unwrap_or(false);
|
|
if !is_cli {
|
|
return Err(Error::msg(format!(
|
|
"a atualização automática vale só para o CLI `guara`; para o app desktop, baixe o instalador em {RELEASES_PAGE_URL}"
|
|
)));
|
|
}
|
|
let body = curl_text(LATEST_RELEASE_URL)?;
|
|
let release: Release = serde_json::from_str(&body)?;
|
|
|
|
let suffix = asset_suffix_for_platform();
|
|
let asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|a| a.name.ends_with(&suffix))
|
|
.ok_or_else(|| {
|
|
Error::msg(format!(
|
|
"nenhum asset terminado em `{suffix}` na release {}",
|
|
release.tag_name
|
|
))
|
|
})?;
|
|
let checksum_asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|a| a.name == format!("{}.sha256", asset.name));
|
|
|
|
let tmp_dir = std::env::temp_dir().join("guara-update");
|
|
fs::create_dir_all(&tmp_dir)?;
|
|
let archive_path = tmp_dir.join(&asset.name);
|
|
curl_download(&asset.browser_download_url, &archive_path)?;
|
|
|
|
if let Some(checksum_asset) = checksum_asset {
|
|
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
|
let expected_hash = expected
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let data = fs::read(&archive_path)?;
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&data);
|
|
let actual_hash: String = hasher
|
|
.finalize()
|
|
.iter()
|
|
.map(|b| format!("{b:02x}"))
|
|
.collect();
|
|
if actual_hash != expected_hash {
|
|
return Err(Error::msg(
|
|
"checksum SHA-256 não confere — atualização descartada",
|
|
));
|
|
}
|
|
}
|
|
|
|
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
|
|
// libarchive/bsdtar) and atomically replace the current executable.
|
|
let extract_dir = tmp_dir.join("extracted");
|
|
let _ = fs::remove_dir_all(&extract_dir);
|
|
fs::create_dir_all(&extract_dir)?;
|
|
let status = Command::new("tar")
|
|
.arg("xf")
|
|
.arg(&archive_path)
|
|
.arg("-C")
|
|
.arg(&extract_dir)
|
|
.status()?;
|
|
if !status.success() {
|
|
return Err(Error::msg("tar xf: extração falhou"));
|
|
}
|
|
|
|
let new_binary = find_binary(&extract_dir)?;
|
|
let backup = current_exe.with_extension("bak");
|
|
let _ = fs::remove_file(&backup);
|
|
fs::rename(current_exe, &backup)?;
|
|
fs::copy(&new_binary, current_exe)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(current_exe)?.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(current_exe, perms)?;
|
|
}
|
|
|
|
Ok(release.tag_name)
|
|
}
|
|
|
|
/// Locates `name` at the archive root or one directory level below
|
|
/// (`guara-vX.Y.Z-<platform>/guara`).
|
|
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
|
fn is_binary(name: &std::ffi::OsStr) -> bool {
|
|
name == "guara" || name == "guara.exe"
|
|
}
|
|
for entry in fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
if is_binary(&entry.file_name()) {
|
|
return Ok(entry.path());
|
|
}
|
|
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
|
for inner in fs::read_dir(entry.path())? {
|
|
let inner = inner?;
|
|
if is_binary(&inner.file_name()) {
|
|
return Ok(inner.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(Error::msg("binário não encontrado no pacote baixado"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn apply_refuses_to_overwrite_the_desktop_app() {
|
|
let err = apply(&PathBuf::from("/opt/guara/guara-gui")).unwrap_err();
|
|
assert!(err.to_string().contains("só para o CLI"), "{err}");
|
|
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
|
|
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
|
|
}
|
|
}
|