diff --git a/backend/server.js b/backend/server.js index d3a3e47..1cac1d2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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()); } diff --git a/desktop-client/package.json b/desktop-client/package.json index c318c80..10a1047 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,7 +1,7 @@ { "name": "desktop-client", "private": true, - "version": "0.1.24", + "version": "0.1.25", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index 01af705..227bdd6 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -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]] diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index 8827f48..b2b52ae 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -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"] } diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index f991581..99ea459 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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 { + 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, 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, 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 { + #[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 { + 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 { 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, 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 { + 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 { 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, diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 0a1ffe5..823d25c 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -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", diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 5b2e26e..da2bcd6 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -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 { if (updateState.checking) { return false; @@ -904,6 +917,8 @@ async function checkClientUpdate(showResultToast = true): Promise { 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 { 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 { 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 { 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, });