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,
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
5
desktop-client/src-tauri/Cargo.lock
generated
5
desktop-client/src-tauri/Cargo.lock
generated
@@ -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]]
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user