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

@@ -112,11 +112,15 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10, 10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
); );
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.24'; const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.25';
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase();
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
const FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend');
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i; const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i;
const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/;
const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时
const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB
const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB
@@ -180,6 +184,72 @@ function normalizeReleaseNotes(rawValue) {
.trim(); .trim();
} }
function normalizeSha256(rawValue) {
const digest = String(rawValue || '').trim().toLowerCase();
return DESKTOP_INSTALLER_SHA256_PATTERN.test(digest) ? digest : '';
}
function normalizeNonNegativeInteger(rawValue, fallback = 0) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value < 0) return Math.max(0, Number(fallback) || 0);
return Math.floor(value);
}
function isPathInside(parent, child) {
const rel = path.relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
function resolveDesktopInstallerLocalPath(installerUrl) {
const raw = String(installerUrl || '').trim();
if (!raw) return null;
try {
const parsed = new URL(raw, 'http://local.invalid');
const pathname = decodeURIComponent(parsed.pathname || '');
const normalizedPath = pathname.replace(/^\/+/, '');
if (!normalizedPath) return null;
const preferredPath = path.resolve(FRONTEND_ROOT_DIR, normalizedPath);
if (isPathInside(FRONTEND_ROOT_DIR, preferredPath) && fs.existsSync(preferredPath) && fs.statSync(preferredPath).isFile()) {
return preferredPath;
}
const fallbackPath = path.resolve(FRONTEND_ROOT_DIR, path.basename(normalizedPath));
if (isPathInside(FRONTEND_ROOT_DIR, fallbackPath) && fs.existsSync(fallbackPath) && fs.statSync(fallbackPath).isFile()) {
return fallbackPath;
}
} catch (error) {
// ignore malformed installer url
}
return null;
}
function computeFileSha256HexSync(filePath) {
const hash = crypto.createHash('sha256');
const content = fs.readFileSync(filePath);
hash.update(content);
return hash.digest('hex');
}
function getLocalDesktopInstallerMeta(installerUrl) {
const localPath = resolveDesktopInstallerLocalPath(installerUrl);
if (!localPath) return null;
try {
const stats = fs.statSync(localPath);
if (!stats.isFile() || stats.size <= 0) return null;
return {
path: localPath,
size: stats.size,
sha256: normalizeSha256(computeFileSha256HexSync(localPath))
};
} catch (error) {
console.warn('[桌面端更新] 读取本地安装包元数据失败:', error.message);
return null;
}
}
function getDesktopUpdateConfig() { function getDesktopUpdateConfig() {
const latestVersion = normalizeVersion( const latestVersion = normalizeVersion(
SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION, SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION,
@@ -197,9 +267,30 @@ function getDesktopUpdateConfig() {
'' ''
); );
const mandatory = SettingsDB.get('desktop_force_update') === 'true'; const mandatory = SettingsDB.get('desktop_force_update') === 'true';
let installerSha256 = normalizeSha256(
SettingsDB.get('desktop_installer_sha256') ||
DEFAULT_DESKTOP_INSTALLER_SHA256
);
let packageSize = normalizeNonNegativeInteger(
SettingsDB.get('desktop_installer_size'),
DEFAULT_DESKTOP_INSTALLER_SIZE
);
if (installerUrl && (!installerSha256 || packageSize <= 0)) {
const localMeta = getLocalDesktopInstallerMeta(installerUrl);
if (localMeta) {
if (!installerSha256 && localMeta.sha256) {
installerSha256 = localMeta.sha256;
}
if (packageSize <= 0 && localMeta.size > 0) {
packageSize = localMeta.size;
}
}
}
return { return {
latestVersion, latestVersion,
installerUrl, installerUrl,
installerSha256,
packageSize,
releaseNotes, releaseNotes,
mandatory mandatory
}; };
@@ -3673,6 +3764,8 @@ app.get('/api/client/desktop-update', (req, res) => {
latestVersion: config.latestVersion, latestVersion: config.latestVersion,
updateAvailable, updateAvailable,
downloadUrl: config.installerUrl, downloadUrl: config.installerUrl,
sha256: config.installerSha256,
packageSize: config.packageSize,
releaseNotes: config.releaseNotes, releaseNotes: config.releaseNotes,
mandatory: config.mandatory && updateAvailable, mandatory: config.mandatory && updateAvailable,
platform, platform,
@@ -6239,13 +6332,13 @@ app.post('/api/upload/resumable/chunk', authMiddleware, upload.single('chunk'),
}); });
} }
const chunkBuffer = fs.readFileSync(req.file.path); const chunkBuffer = await fs.promises.readFile(req.file.path);
const offset = chunkIndex * chunkSize; const offset = chunkIndex * chunkSize;
const fd = fs.openSync(session.temp_file_path, 'r+'); const fd = await fs.promises.open(session.temp_file_path, 'r+');
try { try {
fs.writeSync(fd, chunkBuffer, 0, chunkBuffer.length, offset); await fd.write(chunkBuffer, 0, chunkBuffer.length, offset);
} finally { } finally {
fs.closeSync(fd); await fd.close();
} }
const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks); const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks);
@@ -9060,6 +9153,8 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
desktop_update: { desktop_update: {
latest_version: desktopUpdate.latestVersion, latest_version: desktopUpdate.latestVersion,
installer_url: desktopUpdate.installerUrl, installer_url: desktopUpdate.installerUrl,
installer_sha256: desktopUpdate.installerSha256,
installer_size: desktopUpdate.packageSize,
release_notes: desktopUpdate.releaseNotes, release_notes: desktopUpdate.releaseNotes,
force_update: desktopUpdate.mandatory force_update: desktopUpdate.mandatory
}, },
@@ -9166,8 +9261,41 @@ app.post('/api/admin/settings',
const normalizedInstallerUrl = String(desktop_update.installer_url || '').trim(); const normalizedInstallerUrl = String(desktop_update.installer_url || '').trim();
SettingsDB.set('desktop_installer_url', normalizedInstallerUrl); SettingsDB.set('desktop_installer_url', normalizedInstallerUrl);
SettingsDB.set('desktop_installer_url_win_x64', normalizedInstallerUrl); SettingsDB.set('desktop_installer_url_win_x64', normalizedInstallerUrl);
if (!normalizedInstallerUrl) {
SettingsDB.set('desktop_installer_sha256', '');
SettingsDB.set('desktop_installer_size', '0');
} else {
const localMeta = getLocalDesktopInstallerMeta(normalizedInstallerUrl);
if (localMeta?.sha256) {
SettingsDB.set('desktop_installer_sha256', localMeta.sha256);
}
if (localMeta?.size) {
SettingsDB.set('desktop_installer_size', String(localMeta.size));
}
}
desktopInstallerCleanup = cleanupDesktopInstallerPackages(normalizedInstallerUrl); desktopInstallerCleanup = cleanupDesktopInstallerPackages(normalizedInstallerUrl);
} }
if (desktop_update.installer_sha256 !== undefined) {
const rawDigest = String(desktop_update.installer_sha256 || '').trim().toLowerCase();
const normalizedDigest = normalizeSha256(rawDigest);
if (rawDigest && !normalizedDigest) {
return res.status(400).json({
success: false,
message: '安装包 SHA256 格式无效'
});
}
SettingsDB.set('desktop_installer_sha256', normalizedDigest);
}
if (desktop_update.installer_size !== undefined) {
const rawSize = Number(desktop_update.installer_size);
if (!Number.isFinite(rawSize) || rawSize < 0) {
return res.status(400).json({
success: false,
message: '安装包大小格式无效'
});
}
SettingsDB.set('desktop_installer_size', String(normalizeNonNegativeInteger(rawSize, 0)));
}
if (desktop_update.release_notes !== undefined) { if (desktop_update.release_notes !== undefined) {
SettingsDB.set('desktop_release_notes', String(desktop_update.release_notes || '').trim()); SettingsDB.set('desktop_release_notes', String(desktop_update.release_notes || '').trim());
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "desktop-client" name = "desktop-client"
version = "0.1.24" version = "0.1.25"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -23,7 +23,12 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"] } reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
urlencoding = "2.1" urlencoding = "2.1"
walkdir = "2.5" walkdir = "2.5"
rusqlite = { version = "0.31", features = ["bundled"] } 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 rusqlite::{params, Connection};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::env; use std::env;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
@@ -11,9 +12,16 @@ use std::io::{Read, Seek, SeekFrom};
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter; 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")] #[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
@@ -198,6 +206,153 @@ fn build_chunk_retry_delay(attempt: u32) -> Duration {
Duration::from_millis(ms) 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 { fn fallback_json(status: StatusCode, text: &str) -> Value {
let mut data = Map::new(); let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(status.is_success())); 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> { fn save_login_state_record(base_url: &str, username: &str, password: &str) -> Result<(), String> {
let conn = open_local_state_db()?; let conn = open_local_state_db()?;
let encoded_password = encode_login_password(password)?;
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64) .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, username = excluded.username,
password = excluded.password, password = excluded.password,
updated_at = excluded.updated_at", updated_at = excluded.updated_at",
params![base_url, username, password, now], params![base_url, username, encoded_password, now],
) )
.map_err(|err| format!("保存登录状态失败: {}", err))?; .map_err(|err| format!("保存登录状态失败: {}", err))?;
Ok(()) Ok(())
@@ -347,7 +503,10 @@ fn load_login_state_record() -> Result<Option<(String, String, String)>, String>
)) ))
}); });
match row { 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(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(format!("读取登录状态失败: {}", err)), 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] #[tauri::command]
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> { fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
let path_text = installer_path.trim().to_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?, Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?,
Err(err) => { Err(err) => {
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&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; continue;
} }
return Err(format!("上传分片失败: {}", err)); return Err(format!("上传分片失败: {}", err));
@@ -1577,7 +1770,7 @@ async fn api_upload_file_resumable(
|| message.to_lowercase().contains("timeout") || message.to_lowercase().contains("timeout")
|| message.contains("稍后重试"); || message.contains("稍后重试");
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) { 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; continue;
} }
return Ok(chunk_bridge); return Ok(chunk_bridge);
@@ -1753,6 +1946,7 @@ pub fn run() {
api_delete_share, api_delete_share,
api_create_direct_link, api_create_direct_link,
api_native_download, api_native_download,
api_compute_file_sha256,
api_launch_installer, api_launch_installer,
api_silent_install_and_restart, api_silent_install_and_restart,
api_check_client_update, api_check_client_update,

View File

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

View File

@@ -153,12 +153,14 @@ const syncState = reactive({
nextRunAt: "", nextRunAt: "",
}); });
const updateState = reactive({ const updateState = reactive({
currentVersion: "0.1.24", currentVersion: "0.1.25",
latestVersion: "", latestVersion: "",
available: false, available: false,
mandatory: false, mandatory: false,
checking: false, checking: false,
downloadUrl: "", downloadUrl: "",
packageSha256: "",
packageSize: 0,
releaseNotes: "", releaseNotes: "",
lastCheckedAt: "", lastCheckedAt: "",
message: "", message: "",
@@ -884,6 +886,17 @@ function normalizeReleaseNotesText(raw: string | undefined) {
.trim(); .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> { async function checkClientUpdate(showResultToast = true): Promise<boolean> {
if (updateState.checking) { if (updateState.checking) {
return false; return false;
@@ -904,6 +917,8 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion); updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion);
updateState.available = Boolean(response.data.updateAvailable); updateState.available = Boolean(response.data.updateAvailable);
updateState.downloadUrl = String(response.data.downloadUrl || ""); 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.releaseNotes = normalizeReleaseNotesText(String(response.data.releaseNotes || ""));
updateState.mandatory = Boolean(response.data.mandatory); updateState.mandatory = Boolean(response.data.mandatory);
updateState.message = String(response.data.message || ""); updateState.message = String(response.data.message || "");
@@ -919,6 +934,8 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
updateState.available = false; updateState.available = false;
updateState.downloadUrl = ""; updateState.downloadUrl = "";
updateState.packageSha256 = "";
updateState.packageSize = 0;
updateState.message = String(response.data?.message || "检查更新失败"); updateState.message = String(response.data?.message || "检查更新失败");
if (showResultToast) { if (showResultToast) {
showToast(updateState.message, "error"); showToast(updateState.message, "error");
@@ -984,7 +1001,7 @@ async function installLatestUpdate(): Promise<boolean> {
resetUpdateRuntime(); resetUpdateRuntime();
updateRuntime.downloading = true; updateRuntime.downloading = true;
const taskId = `UPD-${Date.now()}`; 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.taskId = taskId;
updateRuntime.progress = 1; updateRuntime.progress = 1;
updateRuntime.speed = "准备下载"; updateRuntime.speed = "准备下载";
@@ -998,12 +1015,40 @@ async function installLatestUpdate(): Promise<boolean> {
try { try {
if (response.ok && response.data?.success) { if (response.ok && response.data?.success) {
updateRuntime.downloading = false; updateRuntime.downloading = false;
updateRuntime.installing = true;
updateRuntime.progress = 100; updateRuntime.progress = 100;
updateRuntime.speed = "-"; updateRuntime.speed = "校验中";
const savePath = String(response.data?.savePath || "").trim(); const savePath = String(response.data?.savePath || "").trim();
updateRuntime.installerPath = savePath; updateRuntime.installerPath = savePath;
if (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", { const launchResponse = await invokeBridge("api_silent_install_and_restart", {
installerPath: savePath, installerPath: savePath,
}); });