feat(update): unify the updater with Sabiá's Gitea-based mechanism
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
This commit is contained in:
1 parent
6328220c60
commit
76e4466652
29 files changed
+373
-913
No files matched your search
@@ -190,6 +190,11 @@ enum Cmd {
|
||||
Hash { path: String },
|
||||
/// List autosaved sessions
|
||||
Sessions,
|
||||
/// Check for and apply `guara` CLI updates
|
||||
Update {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
/// Open the web UI in the browser (local mode)
|
||||
Ui {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
@@ -666,6 +671,26 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Update { apply } => {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if apply {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let version = guara_core::update::apply(&exe).map_err(|e| e.to_string())?;
|
||||
println!("atualizado para {version}");
|
||||
} else {
|
||||
let status = guara_core::update::check(current).map_err(|e| e.to_string())?;
|
||||
if status.update_available {
|
||||
println!(
|
||||
"atualização disponível: {} -> {} (rode `guara update --apply`)",
|
||||
status.current_version,
|
||||
status.latest_version.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("já na última versão ({})", status.current_version);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod source;
|
||||
pub mod sqlite;
|
||||
pub mod thumb;
|
||||
pub mod types;
|
||||
pub mod update;
|
||||
pub mod vdisk;
|
||||
pub mod volume;
|
||||
|
||||
|
||||
@@ -1303,9 +1303,26 @@ impl Engine {
|
||||
"version" => {
|
||||
json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS })
|
||||
}
|
||||
"update_check" => {
|
||||
let st = self.update_check()?;
|
||||
json!(st)
|
||||
}
|
||||
"update_apply" => {
|
||||
let version = self.update_apply()?;
|
||||
json!({ "version": version })
|
||||
}
|
||||
other => return Err(Error::msg(format!("método desconhecido: {other}"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_check(&self) -> Result<crate::update::UpdateStatus> {
|
||||
crate::update::check(crate::VERSION)
|
||||
}
|
||||
|
||||
pub fn update_apply(&self) -> Result<String> {
|
||||
let exe = std::env::current_exe()?;
|
||||
crate::update::apply(&exe)
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::explorer::Query;
|
||||
|
||||
@@ -23,7 +23,7 @@ pub struct Settings {
|
||||
/// Show OpenStreetMap tiles for geotagged photos (fetches tiles over the network).
|
||||
#[serde(default)]
|
||||
pub maps: bool,
|
||||
/// Check Gitea Releases for updates on startup (fetches `latest.json` once
|
||||
/// Check Gitea Releases for updates on startup (one query to the releases API,
|
||||
/// without sending any telemetry).
|
||||
#[serde(default = "yes")]
|
||||
pub updates: bool,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user