feat(desktop): add offline cache and verified app updates
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
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
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.
This commit is contained in:
1 parent
75ae2092ad
commit
ab49e22357
27 files changed
+785
-39
No files matched your search
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
mod update;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
@@ -110,6 +111,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(update::Updater::default())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
@@ -150,7 +152,8 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
|
||||
update::update_check, update::update_install
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
|
||||
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
Err(_) => {
|
||||
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
|
||||
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
|
||||
response
|
||||
},
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user