Files
pipu-music/apps/desktop/src-tauri/src/update.rs
T
mrleo1nid ab49e22357
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
feat(desktop): add offline cache and verified app updates
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00

153 lines
7.5 KiB
Rust

//! Server-proxied releases: never accept installer paths or commands from the UI.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path, time::Duration};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::Mutex;
#[derive(Clone, Deserialize, Serialize)]
pub struct Build {
client: String,
version: String,
filename: String,
size: u64,
sha256: Option<String>,
download_url: String,
}
#[derive(Deserialize)]
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
#[derive(Clone, Serialize)]
pub struct Release { build: Build, notes: String }
#[derive(Default)]
pub struct Updater { release: Mutex<Option<(String, Release)>> }
fn client() -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
}
fn version_code(raw: &str) -> Option<u64> {
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
let rc: u64 = rc.parse().ok()?;
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
}
fn http() -> Result<reqwest::Client, String> {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
.build().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
let mut saved = updater.release.lock().await;
*saved = None;
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
.json().await.map_err(|e| e.to_string())?;
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
let release = Release { build, notes };
validate_build(&release.build)?;
*saved = Some((server, release.clone()));
Ok(Some(release))
}
fn validate_build(build: &Build) -> Result<(), String> {
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
return Err("Invalid installer URL".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
let saved = updater.release.lock().await;
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
let build = &release.build;
validate_build(build)?;
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&build.filename);
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
let result = async {
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut bytes = 0u64;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
bytes += chunk.len() as u64;
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
hash.update(&chunk);
file.write_all(&chunk).map_err(|e| e.to_string())?;
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
}
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
launch(&path)?;
Ok(())
}.await;
let _ = std::fs::remove_file(&part);
result
}
fn launch(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
} else { std::process::Command::new(path) };
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
#[cfg(target_os = "macos")]
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_order() {
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
assert_eq!(version_code("1.7.1"), Some(1070199));
assert_eq!(version_code("1.7.100"), None);
assert_eq!(version_code("1.7.1;cmd"), None);
}
#[test]
fn installer_is_restricted_to_verified_server_asset() {
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
assert!(validate_build(&build).is_ok());
build.download_url = "https://other.example/installer.exe".into();
assert!(validate_build(&build).is_err());
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
build.filename = "../installer.exe".into();
assert!(validate_build(&build).is_err());
build.filename = format!("pipu.{extension}");
build.sha256 = None;
assert!(validate_build(&build).is_err());
}
}