feat: verify updater package and harden client reliability in 0.1.25
This commit is contained in:
@@ -112,11 +112,15 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
|
||||
10,
|
||||
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_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 FRONTEND_ROOT_DIR = path.resolve(__dirname, '../frontend');
|
||||
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
|
||||
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_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
|
||||
@@ -180,6 +184,72 @@ function normalizeReleaseNotes(rawValue) {
|
||||
.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() {
|
||||
const latestVersion = normalizeVersion(
|
||||
SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION,
|
||||
@@ -197,9 +267,30 @@ function getDesktopUpdateConfig() {
|
||||
''
|
||||
);
|
||||
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 {
|
||||
latestVersion,
|
||||
installerUrl,
|
||||
installerSha256,
|
||||
packageSize,
|
||||
releaseNotes,
|
||||
mandatory
|
||||
};
|
||||
@@ -3673,6 +3764,8 @@ app.get('/api/client/desktop-update', (req, res) => {
|
||||
latestVersion: config.latestVersion,
|
||||
updateAvailable,
|
||||
downloadUrl: config.installerUrl,
|
||||
sha256: config.installerSha256,
|
||||
packageSize: config.packageSize,
|
||||
releaseNotes: config.releaseNotes,
|
||||
mandatory: config.mandatory && updateAvailable,
|
||||
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 fd = fs.openSync(session.temp_file_path, 'r+');
|
||||
const fd = await fs.promises.open(session.temp_file_path, 'r+');
|
||||
try {
|
||||
fs.writeSync(fd, chunkBuffer, 0, chunkBuffer.length, offset);
|
||||
await fd.write(chunkBuffer, 0, chunkBuffer.length, offset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
const uploadedChunks = UploadSessionDB.parseUploadedChunks(session.uploaded_chunks);
|
||||
@@ -9060,6 +9153,8 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
|
||||
desktop_update: {
|
||||
latest_version: desktopUpdate.latestVersion,
|
||||
installer_url: desktopUpdate.installerUrl,
|
||||
installer_sha256: desktopUpdate.installerSha256,
|
||||
installer_size: desktopUpdate.packageSize,
|
||||
release_notes: desktopUpdate.releaseNotes,
|
||||
force_update: desktopUpdate.mandatory
|
||||
},
|
||||
@@ -9166,8 +9261,41 @@ app.post('/api/admin/settings',
|
||||
const normalizedInstallerUrl = String(desktop_update.installer_url || '').trim();
|
||||
SettingsDB.set('desktop_installer_url', 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);
|
||||
}
|
||||
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) {
|
||||
SettingsDB.set('desktop_release_notes', String(desktop_update.release_notes || '').trim());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "desktop-client",
|
||||
"private": true,
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.25",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
5
desktop-client/src-tauri/Cargo.lock
generated
5
desktop-client/src-tauri/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user