feat: verify updater package and harden client reliability in 0.1.25

This commit is contained in:
2026-02-20 22:15:28 +08:00
parent fe544efc91
commit c8f63d6fc9
7 changed files with 393 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "desktop-client",
"private": true,
"version": "0.1.24",
"version": "0.1.25",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -693,18 +693,21 @@ dependencies = [
[[package]]
name = "desktop-client"
version = "0.1.24"
version = "0.1.25"
dependencies = [
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
"urlencoding",
"walkdir",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "desktop-client"
version = "0.1.24"
version = "0.1.25"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -23,7 +23,12 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["time"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
urlencoding = "2.1"
walkdir = "2.5"
rusqlite = { version = "0.31", features = ["bundled"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }

View File

@@ -3,6 +3,7 @@ use reqwest::StatusCode;
use rusqlite::{params, Connection};
use serde::Serialize;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
use std::io::Write;
@@ -11,9 +12,16 @@ use std::io::{Read, Seek, SeekFrom};
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::time::sleep;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::LocalFree;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
@@ -198,6 +206,153 @@ fn build_chunk_retry_delay(attempt: u32) -> Duration {
Duration::from_millis(ms)
}
fn to_hex_string(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for b in bytes {
output.push_str(&format!("{:02x}", b));
}
output
}
fn compute_file_sha256_hex(file_path: &Path) -> Result<String, String> {
let mut file = fs::File::open(file_path).map_err(|err| format!("打开文件失败: {}", err))?;
let mut hasher = Sha256::new();
let mut buf = vec![0_u8; 1024 * 256];
loop {
let read = file
.read(&mut buf)
.map_err(|err| format!("读取文件失败: {}", err))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(to_hex_string(&hasher.finalize()))
}
#[cfg(target_os = "windows")]
fn dpapi_protect_bytes(input: &[u8]) -> Result<Vec<u8>, String> {
if input.is_empty() {
return Ok(Vec::new());
}
let mut in_blob = CRYPT_INTEGER_BLOB {
cbData: input.len() as u32,
pbData: input.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let ok = unsafe {
CryptProtectData(
&mut in_blob,
std::ptr::null(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if ok == 0 {
return Err(format!("加密登录状态失败: {}", std::io::Error::last_os_error()));
}
let data = unsafe {
std::slice::from_raw_parts(out_blob.pbData as *const u8, out_blob.cbData as usize).to_vec()
};
unsafe {
LocalFree(out_blob.pbData as *mut core::ffi::c_void);
}
Ok(data)
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect_bytes(input: &[u8]) -> Result<Vec<u8>, String> {
if input.is_empty() {
return Ok(Vec::new());
}
let mut in_blob = CRYPT_INTEGER_BLOB {
cbData: input.len() as u32,
pbData: input.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let ok = unsafe {
CryptUnprotectData(
&mut in_blob,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if ok == 0 {
return Err(format!("解密登录状态失败: {}", std::io::Error::last_os_error()));
}
let data = unsafe {
std::slice::from_raw_parts(out_blob.pbData as *const u8, out_blob.cbData as usize).to_vec()
};
unsafe {
LocalFree(out_blob.pbData as *mut core::ffi::c_void);
}
Ok(data)
}
fn encode_login_password(raw_password: &str) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let protected = dpapi_protect_bytes(raw_password.as_bytes())?;
return Ok(format!("dpapi:{}", to_hex_string(&protected)));
}
#[cfg(not(target_os = "windows"))]
{
Ok(raw_password.to_string())
}
}
fn decode_login_password(stored_password: &str) -> Result<String, String> {
let raw = stored_password.trim();
if let Some(hex_body) = raw.strip_prefix("dpapi:") {
#[cfg(target_os = "windows")]
{
if hex_body.len() % 2 != 0 {
return Err("登录状态密文格式无效".to_string());
}
let mut encrypted = Vec::with_capacity(hex_body.len() / 2);
let bytes = hex_body.as_bytes();
let mut index = 0;
while index < bytes.len() {
let part = std::str::from_utf8(&bytes[index..index + 2])
.map_err(|_| "登录状态密文格式无效".to_string())?;
let value = u8::from_str_radix(part, 16)
.map_err(|_| "登录状态密文格式无效".to_string())?;
encrypted.push(value);
index += 2;
}
let plain = dpapi_unprotect_bytes(&encrypted)?;
return String::from_utf8(plain).map_err(|_| "登录状态密文解码失败".to_string());
}
#[cfg(not(target_os = "windows"))]
{
let _ = hex_body;
return Err("当前系统不支持读取该登录状态密文".to_string());
}
}
Ok(raw.to_string())
}
fn fallback_json(status: StatusCode, text: &str) -> Value {
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(status.is_success()));
@@ -316,6 +471,7 @@ fn open_local_state_db() -> Result<Connection, String> {
fn save_login_state_record(base_url: &str, username: &str, password: &str) -> Result<(), String> {
let conn = open_local_state_db()?;
let encoded_password = encode_login_password(password)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
@@ -328,7 +484,7 @@ fn save_login_state_record(base_url: &str, username: &str, password: &str) -> Re
username = excluded.username,
password = excluded.password,
updated_at = excluded.updated_at",
params![base_url, username, password, now],
params![base_url, username, encoded_password, now],
)
.map_err(|err| format!("保存登录状态失败: {}", err))?;
Ok(())
@@ -347,7 +503,10 @@ fn load_login_state_record() -> Result<Option<(String, String, String)>, String>
))
});
match row {
Ok(value) => Ok(Some(value)),
Ok((base_url, username, password)) => {
let decoded_password = decode_login_password(&password)?;
Ok(Some((base_url, username, decoded_password)))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(format!("读取登录状态失败: {}", err)),
}
@@ -1107,6 +1266,40 @@ async fn api_native_download(
})
}
#[tauri::command]
fn api_compute_file_sha256(file_path: String) -> Result<BridgeResponse, String> {
let normalized = file_path.trim().to_string();
if normalized.is_empty() {
return Err("文件路径不能为空".to_string());
}
let target = PathBuf::from(&normalized);
if !target.exists() {
return Err("文件不存在".to_string());
}
if !target.is_file() {
return Err("无效的文件路径".to_string());
}
let file_size = fs::metadata(&target)
.map(|meta| meta.len())
.map_err(|err| format!("读取文件大小失败: {}", err))?;
let sha256 = compute_file_sha256_hex(&target)?;
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert("filePath".to_string(), Value::String(normalized));
data.insert("sha256".to_string(), Value::String(sha256));
data.insert(
"fileSize".to_string(),
Value::Number(serde_json::Number::from(file_size)),
);
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
let path_text = installer_path.trim().to_string();
@@ -1548,7 +1741,7 @@ async fn api_upload_file_resumable(
Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?,
Err(err) => {
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&err) {
thread::sleep(build_chunk_retry_delay(attempt));
sleep(build_chunk_retry_delay(attempt)).await;
continue;
}
return Err(format!("上传分片失败: {}", err));
@@ -1577,7 +1770,7 @@ async fn api_upload_file_resumable(
|| message.to_lowercase().contains("timeout")
|| message.contains("稍后重试");
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) {
thread::sleep(build_chunk_retry_delay(attempt));
sleep(build_chunk_retry_delay(attempt)).await;
continue;
}
return Ok(chunk_bridge);
@@ -1753,6 +1946,7 @@ pub fn run() {
api_delete_share,
api_create_direct_link,
api_native_download,
api_compute_file_sha256,
api_launch_installer,
api_silent_install_and_restart,
api_check_client_update,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "玩玩云",
"version": "0.1.24",
"version": "0.1.25",
"identifier": "cn.workyai.wanwancloud.desktop",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -153,12 +153,14 @@ const syncState = reactive({
nextRunAt: "",
});
const updateState = reactive({
currentVersion: "0.1.24",
currentVersion: "0.1.25",
latestVersion: "",
available: false,
mandatory: false,
checking: false,
downloadUrl: "",
packageSha256: "",
packageSize: 0,
releaseNotes: "",
lastCheckedAt: "",
message: "",
@@ -884,6 +886,17 @@ function normalizeReleaseNotesText(raw: string | undefined) {
.trim();
}
function normalizeSha256(raw: string | undefined) {
const digest = String(raw || "").trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(digest) ? digest : "";
}
function normalizePackageSize(raw: unknown) {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.floor(value);
}
async function checkClientUpdate(showResultToast = true): Promise<boolean> {
if (updateState.checking) {
return false;
@@ -904,6 +917,8 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion);
updateState.available = Boolean(response.data.updateAvailable);
updateState.downloadUrl = String(response.data.downloadUrl || "");
updateState.packageSha256 = normalizeSha256(String(response.data.sha256 || ""));
updateState.packageSize = normalizePackageSize(response.data.packageSize);
updateState.releaseNotes = normalizeReleaseNotesText(String(response.data.releaseNotes || ""));
updateState.mandatory = Boolean(response.data.mandatory);
updateState.message = String(response.data.message || "");
@@ -919,6 +934,8 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
updateState.available = false;
updateState.downloadUrl = "";
updateState.packageSha256 = "";
updateState.packageSize = 0;
updateState.message = String(response.data?.message || "检查更新失败");
if (showResultToast) {
showToast(updateState.message, "error");
@@ -984,7 +1001,7 @@ async function installLatestUpdate(): Promise<boolean> {
resetUpdateRuntime();
updateRuntime.downloading = true;
const taskId = `UPD-${Date.now()}`;
const installerName = `玩玩云_v${updateState.latestVersion || updateState.currentVersion}.exe`;
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
updateRuntime.taskId = taskId;
updateRuntime.progress = 1;
updateRuntime.speed = "准备下载";
@@ -998,12 +1015,40 @@ async function installLatestUpdate(): Promise<boolean> {
try {
if (response.ok && response.data?.success) {
updateRuntime.downloading = false;
updateRuntime.installing = true;
updateRuntime.progress = 100;
updateRuntime.speed = "-";
updateRuntime.speed = "校验中";
const savePath = String(response.data?.savePath || "").trim();
updateRuntime.installerPath = savePath;
if (savePath) {
const expectedSha = normalizeSha256(updateState.packageSha256);
const expectedSize = normalizePackageSize(updateState.packageSize);
if (expectedSha || expectedSize > 0) {
const verifyResponse = await invokeBridge("api_compute_file_sha256", {
filePath: savePath,
});
if (!(verifyResponse.ok && verifyResponse.data?.success)) {
const message = String(verifyResponse.data?.message || "校验更新包失败");
resetUpdateRuntime();
showToast(message, "error");
return false;
}
const actualSha = normalizeSha256(String(verifyResponse.data?.sha256 || ""));
const actualSize = normalizePackageSize(verifyResponse.data?.fileSize);
if (expectedSize > 0 && actualSize > 0 && expectedSize !== actualSize) {
resetUpdateRuntime();
showToast(`更新包大小校验失败(期望 ${formatBytes(expectedSize)},实际 ${formatBytes(actualSize)}`, "error");
return false;
}
if (expectedSha && actualSha !== expectedSha) {
resetUpdateRuntime();
showToast("更新包完整性校验失败,请重试下载", "error");
return false;
}
}
updateRuntime.installing = true;
updateRuntime.progress = 100;
updateRuntime.speed = "-";
const launchResponse = await invokeBridge("api_silent_install_and_restart", {
installerPath: savePath,
});