Files

2414 lines
73 KiB
Rust

use reqwest::StatusCode;
use reqwest::{Method, Url};
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;
use std::io::{Read, Seek, SeekFrom};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "windows")]
use std::process::Stdio;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::time::sleep;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::LocalFree;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3;
const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900;
struct ApiState {
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeResponse {
ok: bool,
status: u16,
data: Value,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct NativeDownloadProgressPayload {
task_id: String,
downloaded_bytes: u64,
total_bytes: Option<u64>,
progress: Option<f64>,
resumed_bytes: u64,
done: bool,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct NativeUploadProgressPayload {
task_id: String,
uploaded_bytes: u64,
total_bytes: u64,
progress: f64,
done: bool,
}
fn emit_native_download_progress(
window: &tauri::WebviewWindow,
task_id: &str,
downloaded_bytes: u64,
total_bytes: Option<u64>,
resumed_bytes: u64,
done: bool,
) {
if task_id.trim().is_empty() {
return;
}
let progress = total_bytes
.filter(|total| *total > 0)
.map(|total| (downloaded_bytes as f64 / total as f64) * 100.0);
let payload = NativeDownloadProgressPayload {
task_id: task_id.to_string(),
downloaded_bytes,
total_bytes,
progress,
resumed_bytes,
done,
};
if let Err(err) = window.emit("native-download-progress", payload) {
eprintln!("emit native-download-progress failed: {}", err);
}
}
fn emit_native_upload_progress(
window: &tauri::WebviewWindow,
task_id: &str,
uploaded_bytes: u64,
total_bytes: u64,
done: bool,
) {
if task_id.trim().is_empty() {
return;
}
let normalized_total = total_bytes.max(1);
let progress = (uploaded_bytes as f64 / normalized_total as f64) * 100.0;
let payload = NativeUploadProgressPayload {
task_id: task_id.to_string(),
uploaded_bytes,
total_bytes,
progress,
done,
};
if let Err(err) = window.emit("native-upload-progress", payload) {
eprintln!("emit native-upload-progress failed: {}", err);
}
}
fn normalize_base_url(base_url: &str) -> String {
let trimmed = base_url.trim();
if trimmed.is_empty() {
return String::new();
}
trimmed.trim_end_matches('/').to_string()
}
fn join_api_url(base_url: &str, path: &str) -> String {
format!("{}{}", normalize_base_url(base_url), path)
}
fn sanitize_device_id_component(raw: &str) -> String {
let mut output = String::new();
let mut last_is_dash = false;
for ch in raw.chars() {
let normalized = ch.to_ascii_lowercase();
if normalized.is_ascii_alphanumeric() {
output.push(normalized);
last_is_dash = false;
} else if !last_is_dash {
output.push('-');
last_is_dash = true;
}
}
output.trim_matches('-').to_string()
}
fn build_desktop_client_meta() -> (String, String, String) {
let os = match env::consts::OS {
"windows" => "Windows",
"macos" => "macOS",
"linux" => "Linux",
other => other,
};
let platform = format!("{}-{}", os, env::consts::ARCH);
let host_name = env::var("COMPUTERNAME")
.or_else(|_| env::var("HOSTNAME"))
.unwrap_or_default();
let host_trimmed = host_name.trim();
let device_name = if host_trimmed.is_empty() {
format!("桌面客户端 · {}", platform)
} else {
format!("{} · {}", host_trimmed, platform)
};
let id_seed = if host_trimmed.is_empty() {
platform.clone()
} else {
format!("{}-{}", host_trimmed, platform)
};
let normalized = sanitize_device_id_component(&id_seed);
let device_id = if normalized.is_empty() {
"desktop-client".to_string()
} else {
format!("desktop-{}", normalized)
};
(platform, device_name, device_id)
}
fn is_retryable_upload_status(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
}
fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
err.is_timeout() || err.is_connect() || err.is_request() || err.is_body()
}
fn build_chunk_retry_delay(attempt: u32) -> Duration {
let multiplier = 2_u64.saturating_pow(attempt.min(5));
let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS
.saturating_mul(multiplier)
.min(15_000);
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()));
data.insert(
"message".to_string(),
Value::String(if text.trim().is_empty() {
format!("HTTP {}", status.as_u16())
} else {
text.to_string()
}),
);
Value::Object(data)
}
fn sanitize_file_name(name: &str) -> String {
let raw = name.trim();
let mut cleaned = String::with_capacity(raw.len());
for ch in raw.chars() {
if matches!(
ch,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
) {
cleaned.push('_');
} else {
cleaned.push(ch);
}
}
let normalized = cleaned.trim().trim_matches('.').to_string();
if normalized.is_empty() {
"download.bin".to_string()
} else {
normalized
}
}
fn resolve_download_dir() -> PathBuf {
if let Some(home) = env::var_os("USERPROFILE") {
return PathBuf::from(home).join("Downloads");
}
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join("Downloads");
}
PathBuf::from(".")
}
fn split_file_name(name: &str) -> (String, String) {
if let Some(index) = name.rfind('.') {
if index > 0 && index < name.len() - 1 {
let stem = name[..index].to_string();
let ext = name[index + 1..].to_string();
return (stem, ext);
}
}
(name.to_string(), String::new())
}
fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
let safe_name = sanitize_file_name(preferred_name);
let first = download_dir.join(&safe_name);
if !first.exists() {
return first;
}
let (stem, ext) = split_file_name(&safe_name);
for index in 1..10000 {
let candidate_name = if ext.is_empty() {
format!("{} ({})", stem, index)
} else {
format!("{} ({}).{}", stem, index, ext)
};
let candidate = download_dir.join(candidate_name);
if !candidate.exists() {
return candidate;
}
}
first
}
fn build_download_resume_temp_path(
download_dir: &Path,
preferred_name: &str,
url: &str,
) -> PathBuf {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
preferred_name.hash(&mut hasher);
let resume_identity = Url::parse(url)
.map(|parsed| {
let host = parsed.host_str().unwrap_or_default();
let port = parsed
.port()
.map(|value| format!(":{}", value))
.unwrap_or_default();
format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
})
.unwrap_or_else(|_| url.to_string());
resume_identity.hash(&mut hasher);
let digest = format!("{:016x}", hasher.finish());
let safe_name = sanitize_file_name(preferred_name);
let temp_name = format!(".{}.{}.part", safe_name, digest);
download_dir.join(temp_name)
}
fn is_update_installer_file_name(file_name: &str) -> bool {
let lower = file_name.trim().to_ascii_lowercase();
if !lower.ends_with(".exe") {
return false;
}
lower.starts_with("wanwan-cloud-desktop_v") || file_name.trim().starts_with("玩玩云_v")
}
fn ensure_http_download_url(raw_url: &str) -> Result<(), String> {
let parsed = Url::parse(raw_url).map_err(|_| "下载地址格式无效".to_string())?;
match parsed.scheme() {
"http" | "https" => Ok(()),
_ => Err("仅允许 HTTP/HTTPS 下载地址".to_string()),
}
}
fn validate_update_installer_path(installer_path: &str) -> Result<PathBuf, String> {
let path_text = installer_path.trim();
if path_text.is_empty() {
return Err("安装包路径不能为空".to_string());
}
let installer = PathBuf::from(path_text);
if !installer.exists() {
return Err("安装包不存在,请重新下载".to_string());
}
if !installer.is_file() {
return Err("安装包路径无效".to_string());
}
let file_name = installer
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "安装包文件名无效".to_string())?;
if !is_update_installer_file_name(file_name) {
return Err("仅允许启动玩玩云官方更新安装包".to_string());
}
let download_dir = resolve_download_dir()
.canonicalize()
.map_err(|err| format!("读取下载目录失败: {}", err))?;
let canonical_installer = installer
.canonicalize()
.map_err(|err| format!("读取安装包路径失败: {}", err))?;
if !canonical_installer.starts_with(&download_dir) {
return Err("安装包必须位于系统下载目录内".to_string());
}
Ok(canonical_installer)
}
fn cleanup_old_update_installers(
download_dir: &Path,
keep_file_name: &str,
keep_latest: usize,
) -> Result<(), String> {
let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new();
let normalized_keep = keep_file_name.trim();
for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))?
{
let path = match entry {
Ok(item) => item.path(),
Err(_) => continue,
};
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !is_update_installer_file_name(file_name) {
continue;
}
let modified = fs::metadata(&path)
.ok()
.and_then(|meta| meta.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
entries.push((path, modified));
}
entries.sort_by(|a, b| b.1.cmp(&a.1));
let retain_count = keep_latest.max(1);
let mut retained = 0usize;
for (path, _) in entries {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string();
let should_keep = file_name == normalized_keep || retained < retain_count;
if should_keep {
retained += 1;
continue;
}
let _ = fs::remove_file(&path);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn powershell_single_quoted_path(path: &Path) -> String {
path.to_string_lossy().replace('\'', "''")
}
#[cfg(target_os = "windows")]
fn build_windows_update_script(
installer: &Path,
app_exe: &Path,
app_pid: u32,
log_file: &Path,
) -> String {
let template = r#"$ErrorActionPreference = 'Continue'
$Installer = '__INSTALLER__'
$AppExe = '__APP_EXE__'
$AppPid = __APP_PID__
$LogFile = '__LOG_FILE__'
function Write-UpdateLog([string]$Message) {
Add-Content -LiteralPath $LogFile -Encoding UTF8 -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message)
}
Write-UpdateLog 'update script started'
if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
Write-UpdateLog ('installer not found: ' + $Installer)
exit 2
}
Start-Sleep -Milliseconds 700
Stop-Process -Id $AppPid -Force -ErrorAction SilentlyContinue
for ($Attempt = 0; $Attempt -lt 20; $Attempt++) {
if (-not (Get-Process -Id $AppPid -ErrorAction SilentlyContinue)) { break }
Start-Sleep -Milliseconds 250
}
$InstallExit = 1
try {
$InstallerProcess = Start-Process -FilePath $Installer -ArgumentList '/S' -WindowStyle Hidden -Wait -PassThru -ErrorAction Stop
$InstallExit = $InstallerProcess.ExitCode
Write-UpdateLog ('installer exit code: ' + $InstallExit)
} catch {
Write-UpdateLog ('installer failed: ' + $_.Exception.Message)
}
$Candidates = @(
$AppExe,
(Join-Path $env:LOCALAPPDATA '玩玩云\desktop-client.exe')
)
$TargetApp = $null
for ($Attempt = 0; $Attempt -lt 40 -and -not $TargetApp; $Attempt++) {
foreach ($Candidate in $Candidates) {
if ($Candidate -and (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
$TargetApp = $Candidate
break
}
}
if (-not $TargetApp) { Start-Sleep -Milliseconds 500 }
}
if ($TargetApp) {
try {
Start-Process -FilePath $TargetApp -ErrorAction Stop
Write-UpdateLog ('application restarted: ' + $TargetApp)
} catch {
Write-UpdateLog ('application restart failed: ' + $_.Exception.Message)
}
} else {
Write-UpdateLog 'application executable not found after install'
}
Start-Sleep -Milliseconds 300
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
exit $InstallExit
"#;
let script = template
.replace("__INSTALLER__", &powershell_single_quoted_path(installer))
.replace("__APP_EXE__", &powershell_single_quoted_path(app_exe))
.replace("__APP_PID__", &app_pid.to_string())
.replace("__LOG_FILE__", &powershell_single_quoted_path(log_file))
.replace('\n', "\r\n");
format!("\u{feff}{}", script)
}
#[cfg(target_os = "windows")]
fn spawn_windows_update_script(
script_path: &Path,
working_dir: &Path,
) -> std::io::Result<std::process::Child> {
Command::new("powershell.exe")
.arg("-NoLogo")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-WindowStyle")
.arg("Hidden")
.arg("-File")
.arg(script_path)
.current_dir(working_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()
}
#[cfg(all(test, target_os = "windows"))]
mod windows_update_tests {
use super::*;
#[test]
fn update_script_is_utf8_and_does_not_spawn_timeout_processes() {
let script = build_windows_update_script(
Path::new(r"C:\downloads\release's setup.exe"),
Path::new(r"C:\Users\tester\AppData\Local\玩玩云\desktop-client.exe"),
4242,
Path::new(r"C:\Temp\silent-update.log"),
);
assert!(script.starts_with('\u{feff}'));
assert!(!script.to_ascii_lowercase().contains("timeout /t"));
assert!(script.contains("Start-Sleep"));
assert!(script.contains("-WindowStyle Hidden -Wait -PassThru"));
assert!(script.contains(r"$Installer = 'C:\downloads\release''s setup.exe'"));
assert!(script.contains(r"玩玩云\desktop-client.exe"));
}
#[test]
fn updater_powershell_process_runs_hidden_script() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let test_dir = env::temp_dir().join(format!("玩玩云-updater-test-{}", stamp));
fs::create_dir_all(&test_dir).expect("create updater test directory");
let script_path = test_dir.join("probe.ps1");
let marker_path = test_dir.join("probe-ok.txt");
let script = format!(
"\u{feff}Start-Sleep -Milliseconds 120\r\nSet-Content -LiteralPath '{}' -Value 'ok' -Encoding UTF8\r\n",
powershell_single_quoted_path(&marker_path)
);
fs::write(&script_path, script.as_bytes()).expect("write updater probe script");
let mut child = spawn_windows_update_script(&script_path, &test_dir)
.expect("spawn hidden updater powershell");
let status = child.wait().expect("wait for updater powershell");
assert!(status.success(), "powershell exited with {status}");
assert!(
marker_path.is_file(),
"updater script did not create marker"
);
let _ = fs::remove_dir_all(test_dir);
}
}
fn resolve_local_state_dir() -> PathBuf {
if let Some(appdata) = env::var_os("APPDATA") {
return PathBuf::from(appdata).join("wanwan-cloud-desktop");
}
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join(".wanwan-cloud-desktop");
}
PathBuf::from(".").join(".wanwan-cloud-desktop")
}
fn open_local_state_db() -> Result<Connection, String> {
let state_dir = resolve_local_state_dir();
fs::create_dir_all(&state_dir).map_err(|err| format!("创建本地状态目录失败: {}", err))?;
let db_path = state_dir.join("client_state.db");
let conn =
Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS login_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at INTEGER NOT NULL
)",
[],
)
.map_err(|err| format!("初始化本地状态表失败: {}", err))?;
Ok(conn)
}
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)
.unwrap_or_default();
conn.execute(
"INSERT INTO login_state (id, base_url, username, password, updated_at)
VALUES (1, ?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET
base_url = excluded.base_url,
username = excluded.username,
password = excluded.password,
updated_at = excluded.updated_at",
params![base_url, username, encoded_password, now],
)
.map_err(|err| format!("保存登录状态失败: {}", err))?;
Ok(())
}
fn load_login_state_record() -> Result<Option<(String, String, String)>, String> {
let conn = open_local_state_db()?;
let mut stmt = conn
.prepare("SELECT base_url, username, password FROM login_state WHERE id = 1 LIMIT 1")
.map_err(|err| format!("读取登录状态失败: {}", err))?;
let row = stmt.query_row([], |record| {
Ok((
record.get::<_, String>(0)?,
record.get::<_, String>(1)?,
record.get::<_, String>(2)?,
))
});
drop(stmt);
match row {
Ok((base_url, username, password)) => match decode_login_password(&password) {
Ok(decoded_password) => Ok(Some((base_url, username, decoded_password))),
Err(err) => {
eprintln!("decode login state failed, clearing invalid state: {}", err);
let _ = conn.execute("DELETE FROM login_state WHERE id = 1", []);
Ok(None)
}
},
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(format!("读取登录状态失败: {}", err)),
}
}
fn clear_login_state_record() -> Result<(), String> {
let conn = open_local_state_db()?;
conn.execute("DELETE FROM login_state WHERE id = 1", [])
.map_err(|err| format!("清除登录状态失败: {}", err))?;
Ok(())
}
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取响应失败: {}", err))?;
let data = match serde_json::from_str::<Value>(&text) {
Ok(parsed) => parsed,
Err(_) => fallback_json(status, &text),
};
Ok(BridgeResponse {
ok: status.is_success(),
status: status.as_u16(),
data,
})
}
async fn request_json(
client: &reqwest::Client,
method: Method,
url: String,
body: Option<Value>,
csrf_token: Option<String>,
) -> Result<BridgeResponse, String> {
if url.is_empty() {
return Err("API 地址不能为空".to_string());
}
let mut request = client
.request(method, &url)
.header("Accept", "application/json")
.header("Content-Type", "application/json");
if let Some(csrf) = csrf_token {
request = request.header("X-CSRF-Token", csrf);
}
if let Some(payload) = body {
request = request.json(&payload);
}
let response = request
.send()
.await
.map_err(|err| format!("请求失败: {}", err))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取响应失败: {}", err))?;
let data = match serde_json::from_str::<Value>(&text) {
Ok(parsed) => parsed,
Err(_) => fallback_json(status, &text),
};
Ok(BridgeResponse {
ok: status.is_success(),
status: status.as_u16(),
data,
})
}
async fn fetch_csrf_token(
client: &reqwest::Client,
base_url: &str,
) -> Result<Option<String>, String> {
let response = request_json(
client,
Method::GET,
join_api_url(base_url, "/api/csrf-token"),
None,
None,
)
.await?;
let token = response
.data
.get("csrfToken")
.and_then(Value::as_str)
.map(|v| v.to_string());
Ok(token)
}
async fn request_with_optional_csrf(
client: &reqwest::Client,
method: Method,
base_url: &str,
path: &str,
body: Option<Value>,
need_csrf: bool,
) -> Result<BridgeResponse, String> {
let csrf_token = if need_csrf {
fetch_csrf_token(client, base_url).await?
} else {
None
};
request_json(
client,
method,
join_api_url(base_url, path),
body,
csrf_token,
)
.await
}
#[tauri::command]
async fn api_login(
state: tauri::State<'_, ApiState>,
base_url: String,
username: String,
password: String,
captcha: Option<String>,
) -> Result<BridgeResponse, String> {
let (platform, device_name, device_id) = build_desktop_client_meta();
let mut body = Map::new();
body.insert("username".to_string(), Value::String(username));
body.insert("password".to_string(), Value::String(password));
body.insert(
"client_type".to_string(),
Value::String("desktop".to_string()),
);
body.insert("platform".to_string(), Value::String(platform));
body.insert("device_name".to_string(), Value::String(device_name));
body.insert("device_id".to_string(), Value::String(device_id));
if let Some(value) = captcha {
if !value.trim().is_empty() {
body.insert("captcha".to_string(), Value::String(value));
}
}
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/login",
Some(Value::Object(body)),
false,
)
.await
}
#[tauri::command]
async fn api_get_profile(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::GET,
&base_url,
"/api/user/profile",
None,
false,
)
.await
}
#[tauri::command]
async fn api_list_online_devices(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::GET,
&base_url,
"/api/user/online-devices",
None,
false,
)
.await
}
#[tauri::command]
async fn api_kick_online_device(
state: tauri::State<'_, ApiState>,
base_url: String,
session_id: String,
) -> Result<BridgeResponse, String> {
let session = session_id.trim().to_string();
if session.is_empty() {
return Err("会话标识不能为空".to_string());
}
if session.len() > 128 {
return Err("会话标识长度无效".to_string());
}
let api_path = format!(
"/api/user/online-devices/{}/kick",
urlencoding::encode(&session)
);
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
&api_path,
Some(Value::Object(Map::new())),
true,
)
.await
}
#[tauri::command]
fn api_save_login_state(
base_url: String,
username: String,
password: String,
) -> Result<BridgeResponse, String> {
let normalized_base = normalize_base_url(&base_url);
let normalized_user = username.trim().to_string();
if normalized_base.is_empty() {
return Err("服务地址不能为空".to_string());
}
if normalized_user.is_empty() {
return Err("用户名不能为空".to_string());
}
if password.trim().is_empty() {
return Err("密码不能为空".to_string());
}
save_login_state_record(&normalized_base, &normalized_user, &password)?;
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"message".to_string(),
Value::String("登录状态已保存".to_string()),
);
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
fn api_load_login_state() -> Result<BridgeResponse, String> {
let state = load_login_state_record()?;
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
if let Some((base_url, username, password)) = state {
data.insert("hasState".to_string(), Value::Bool(true));
data.insert("baseUrl".to_string(), Value::String(base_url));
data.insert("username".to_string(), Value::String(username));
data.insert("password".to_string(), Value::String(password));
} else {
data.insert("hasState".to_string(), Value::Bool(false));
}
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
fn api_clear_login_state() -> Result<BridgeResponse, String> {
clear_login_state_record()?;
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"message".to_string(),
Value::String("登录状态已清除".to_string()),
);
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
async fn api_list_files(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
) -> Result<BridgeResponse, String> {
let normalized = if path.trim().is_empty() {
"/".to_string()
} else {
path
};
let encoded = urlencoding::encode(&normalized);
let api_url = format!("{}?path={}", join_api_url(&base_url, "/api/files"), encoded);
request_json(&state.client, Method::GET, api_url, None, None).await
}
#[tauri::command]
async fn api_logout(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/logout",
Some(Value::Object(Map::new())),
true,
)
.await
}
#[tauri::command]
async fn api_refresh_token(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/refresh-token"),
Some(Value::Object(Map::new())),
None,
)
.await
}
#[tauri::command]
async fn api_search_files(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
keyword: String,
search_type: Option<String>,
limit: Option<u32>,
) -> Result<BridgeResponse, String> {
let normalized_path = if path.trim().is_empty() {
"/".to_string()
} else {
path
};
let kind = search_type
.unwrap_or_else(|| "all".to_string())
.trim()
.to_string();
let max_limit = limit.unwrap_or(100).clamp(1, 500);
let api_url = format!(
"{}?path={}&keyword={}&type={}&limit={}",
join_api_url(&base_url, "/api/files/search"),
urlencoding::encode(&normalized_path),
urlencoding::encode(&keyword),
urlencoding::encode(&kind),
max_limit
);
request_json(&state.client, Method::GET, api_url, None, None).await
}
#[tauri::command]
async fn api_mkdir(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
folder_name: String,
) -> Result<BridgeResponse, String> {
let mut body = Map::new();
body.insert("path".to_string(), Value::String(path));
body.insert("folderName".to_string(), Value::String(folder_name));
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/files/mkdir",
Some(Value::Object(body)),
true,
)
.await
}
#[tauri::command]
async fn api_rename_file(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
old_name: String,
new_name: String,
) -> Result<BridgeResponse, String> {
let mut body = Map::new();
body.insert("path".to_string(), Value::String(path));
body.insert("oldName".to_string(), Value::String(old_name));
body.insert("newName".to_string(), Value::String(new_name));
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/files/rename",
Some(Value::Object(body)),
true,
)
.await
}
#[tauri::command]
async fn api_delete_file(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
file_name: String,
) -> Result<BridgeResponse, String> {
let mut body = Map::new();
body.insert("path".to_string(), Value::String(path));
body.insert("fileName".to_string(), Value::String(file_name));
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/files/delete",
Some(Value::Object(body)),
true,
)
.await
}
#[tauri::command]
async fn api_get_download_url(
state: tauri::State<'_, ApiState>,
base_url: String,
path: String,
mode: Option<String>,
) -> Result<BridgeResponse, String> {
let normalized_mode = mode.unwrap_or_else(|| "download".to_string());
let api_url = format!(
"{}?path={}&mode={}",
join_api_url(&base_url, "/api/files/download-url"),
urlencoding::encode(&path),
urlencoding::encode(&normalized_mode)
);
request_json(&state.client, Method::GET, api_url, None, None).await
}
#[tauri::command]
async fn api_get_my_shares(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::GET,
&base_url,
"/api/share/my",
None,
false,
)
.await
}
#[tauri::command]
async fn api_get_my_direct_links(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::GET,
&base_url,
"/api/direct-link/my",
None,
false,
)
.await
}
#[tauri::command]
async fn api_delete_direct_link(
state: tauri::State<'_, ApiState>,
base_url: String,
link_id: i64,
) -> Result<BridgeResponse, String> {
if link_id <= 0 {
return Err("无效的直链ID".to_string());
}
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
let path = format!("/api/direct-link/{}", link_id);
request_json(
&state.client,
Method::DELETE,
join_api_url(&base_url, &path),
None,
csrf_token,
)
.await
}
#[tauri::command]
async fn api_create_share(
state: tauri::State<'_, ApiState>,
base_url: String,
share_type: String,
file_path: String,
file_name: Option<String>,
password: Option<String>,
expiry_days: Option<i32>,
max_downloads: Option<u64>,
ip_whitelist: Option<String>,
device_limit: Option<String>,
access_time_start: Option<String>,
access_time_end: Option<String>,
) -> Result<BridgeResponse, String> {
let mut body = Map::new();
body.insert("share_type".to_string(), Value::String(share_type));
body.insert("file_path".to_string(), Value::String(file_path));
if let Some(name) = file_name {
if !name.trim().is_empty() {
body.insert(
"file_name".to_string(),
Value::String(name.trim().to_string()),
);
}
}
if let Some(raw_password) = password {
let value = raw_password.trim();
if value.is_empty() {
body.insert("password".to_string(), Value::Null);
} else {
body.insert("password".to_string(), Value::String(value.to_string()));
}
}
if let Some(days) = expiry_days {
if days > 0 {
body.insert("expiry_days".to_string(), Value::Number(days.into()));
} else {
body.insert("expiry_days".to_string(), Value::Null);
}
} else {
body.insert("expiry_days".to_string(), Value::Null);
}
if let Some(limit) = max_downloads {
if limit > 0 {
body.insert("max_downloads".to_string(), Value::Number(limit.into()));
}
}
if let Some(value) = ip_whitelist {
let normalized = value.trim();
if !normalized.is_empty() {
body.insert(
"ip_whitelist".to_string(),
Value::String(normalized.to_string()),
);
}
}
if let Some(value) = device_limit {
let normalized = value.trim();
if !normalized.is_empty() {
body.insert(
"device_limit".to_string(),
Value::String(normalized.to_string()),
);
}
}
if let Some(value) = access_time_start {
let normalized = value.trim();
if !normalized.is_empty() {
body.insert(
"access_time_start".to_string(),
Value::String(normalized.to_string()),
);
}
}
if let Some(value) = access_time_end {
let normalized = value.trim();
if !normalized.is_empty() {
body.insert(
"access_time_end".to_string(),
Value::String(normalized.to_string()),
);
}
}
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/share/create",
Some(Value::Object(body)),
true,
)
.await
}
#[tauri::command]
async fn api_delete_share(
state: tauri::State<'_, ApiState>,
base_url: String,
share_id: u64,
) -> Result<BridgeResponse, String> {
if share_id == 0 {
return Err("无效的分享ID".to_string());
}
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
let path = format!("/api/share/{}", share_id);
request_json(
&state.client,
Method::DELETE,
join_api_url(&base_url, &path),
None,
csrf_token,
)
.await
}
#[tauri::command]
async fn api_create_direct_link(
state: tauri::State<'_, ApiState>,
base_url: String,
file_path: String,
file_name: Option<String>,
expiry_days: Option<i32>,
) -> Result<BridgeResponse, String> {
let mut body = Map::new();
body.insert("file_path".to_string(), Value::String(file_path));
if let Some(name) = file_name {
if !name.trim().is_empty() {
body.insert(
"file_name".to_string(),
Value::String(name.trim().to_string()),
);
}
}
if let Some(days) = expiry_days {
if days > 0 {
body.insert("expiry_days".to_string(), Value::Number(days.into()));
} else {
body.insert("expiry_days".to_string(), Value::Null);
}
} else {
body.insert("expiry_days".to_string(), Value::Null);
}
request_with_optional_csrf(
&state.client,
Method::POST,
&base_url,
"/api/direct-link/create",
Some(Value::Object(body)),
true,
)
.await
}
#[tauri::command]
async fn api_native_download(
state: tauri::State<'_, ApiState>,
window: tauri::WebviewWindow,
url: String,
file_name: Option<String>,
task_id: Option<String>,
) -> Result<BridgeResponse, String> {
let trimmed_url = url.trim().to_string();
if trimmed_url.is_empty() {
return Err("下载地址不能为空".to_string());
}
ensure_http_download_url(&trimmed_url)?;
let preferred_name = file_name
.as_deref()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.unwrap_or("download.bin");
let download_dir = resolve_download_dir();
if !download_dir.exists() {
fs::create_dir_all(&download_dir).map_err(|err| format!("创建下载目录失败: {}", err))?;
}
let resume_temp_path =
build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url);
let existing_size = if resume_temp_path.exists() {
fs::metadata(&resume_temp_path)
.ok()
.map(|meta| meta.len())
.unwrap_or(0)
} else {
0
};
let mut request = state.client.get(&trimmed_url);
if existing_size > 0 {
request = request.header("Range", format!("bytes={}-", existing_size));
}
let response = request
.send()
.await
.map_err(|err| format!("下载请求失败: {}", err))?;
let status = response.status();
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 {
let remote_size = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("bytes */"))
.and_then(|value| value.parse::<u64>().ok());
if remote_size != Some(existing_size) {
let _ = fs::remove_file(&resume_temp_path);
return Err("断点文件与远端文件不一致,已清理临时文件,请重试".to_string());
}
let save_path = alloc_download_path(&download_dir, preferred_name);
fs::rename(&resume_temp_path, &save_path)
.map_err(|err| format!("完成断点下载失败: {}", err))?;
if let Some(saved_name) = save_path.file_name().and_then(|name| name.to_str()) {
if is_update_installer_file_name(saved_name) {
let _ = cleanup_old_update_installers(&download_dir, saved_name, 3);
}
}
if let Some(ref id) = task_id {
emit_native_download_progress(
&window,
id,
existing_size,
Some(existing_size),
existing_size,
true,
);
}
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"savePath".to_string(),
Value::String(save_path.to_string_lossy().to_string()),
);
data.insert(
"downloadedBytes".to_string(),
Value::Number(serde_json::Number::from(existing_size)),
);
data.insert(
"resumedBytes".to_string(),
Value::Number(serde_json::Number::from(existing_size)),
);
return Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
});
}
if !status.is_success() {
return Ok(BridgeResponse {
ok: false,
status: status.as_u16(),
data: fallback_json(status, "下载失败"),
});
}
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
let total_bytes = if append_mode {
response
.content_length()
.map(|remaining| remaining.saturating_add(existing_size))
} else {
response.content_length()
};
let resumed_bytes = if append_mode { existing_size } else { 0 };
if let Some(ref id) = task_id {
emit_native_download_progress(
&window,
id,
if append_mode { existing_size } else { 0 },
total_bytes,
resumed_bytes,
false,
);
}
if !append_mode && resume_temp_path.exists() {
fs::remove_file(&resume_temp_path)
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
}
let mut target_file = fs::OpenOptions::new()
.create(true)
.write(true)
.append(append_mode)
.truncate(!append_mode)
.open(&resume_temp_path)
.map_err(|err| format!("创建文件失败: {}", err))?;
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
let mut stream = response;
let mut last_emit = Instant::now();
while let Some(chunk) = stream
.chunk()
.await
.map_err(|err| format!("读取下载流失败: {}", err))?
{
target_file
.write_all(&chunk)
.map_err(|err| format!("写入文件失败: {}", err))?;
downloaded_bytes += chunk.len() as u64;
if let Some(ref id) = task_id {
if last_emit.elapsed() >= Duration::from_millis(120) {
emit_native_download_progress(
&window,
id,
downloaded_bytes,
total_bytes,
resumed_bytes,
false,
);
last_emit = Instant::now();
}
}
}
target_file
.flush()
.map_err(|err| format!("刷新文件失败: {}", err))?;
let save_path = alloc_download_path(&download_dir, preferred_name);
fs::rename(&resume_temp_path, &save_path)
.map_err(|err| format!("保存下载文件失败: {}", err))?;
if let Some(saved_name) = save_path.file_name().and_then(|name| name.to_str()) {
if is_update_installer_file_name(saved_name) {
let _ = cleanup_old_update_installers(&download_dir, saved_name, 3);
}
}
if let Some(ref id) = task_id {
emit_native_download_progress(
&window,
id,
downloaded_bytes,
total_bytes.or(Some(downloaded_bytes)),
resumed_bytes,
true,
);
}
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"savePath".to_string(),
Value::String(save_path.to_string_lossy().to_string()),
);
data.insert(
"downloadedBytes".to_string(),
Value::Number(serde_json::Number::from(downloaded_bytes)),
);
data.insert(
"resumedBytes".to_string(),
Value::Number(serde_json::Number::from(if append_mode {
existing_size
} else {
0
})),
);
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[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 installer = validate_update_installer_path(&installer_path)?;
let path_text = installer.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let spawn_result = Command::new(&installer).spawn();
#[cfg(target_os = "macos")]
let spawn_result = Command::new("open").arg(&installer).spawn();
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let spawn_result = Command::new("xdg-open").arg(&installer).spawn();
spawn_result.map_err(|err| format!("启动安装程序失败: {}", err))?;
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"message".to_string(),
Value::String("安装程序已启动".to_string()),
);
data.insert("installerPath".to_string(), Value::String(path_text));
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeResponse, String> {
let installer = validate_update_installer_path(&installer_path)?;
let path_text = installer.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let windows_log_file_path: String;
#[cfg(target_os = "windows")]
let windows_script_file_path: String;
#[cfg(target_os = "windows")]
{
let current_exe =
env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?;
let current_pid = std::process::id();
let temp_dir = env::temp_dir().join("wanwan-cloud-desktop");
fs::create_dir_all(&temp_dir).map_err(|err| format!("创建更新脚本目录失败: {}", err))?;
let script_stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let script_path = temp_dir.join(format!("silent-update-{}.ps1", script_stamp));
let log_path = temp_dir.join(format!("silent-update-{}.log", script_stamp));
windows_log_file_path = log_path.to_string_lossy().to_string();
windows_script_file_path = script_path.to_string_lossy().to_string();
let bootstrap_content = format!(
"[bootstrap] silent updater prepared\r\npid={}\r\ninstaller={}\r\nscript={}\r\n",
current_pid,
installer.to_string_lossy(),
script_path.to_string_lossy()
);
fs::write(&log_path, bootstrap_content)
.map_err(|err| format!("写入更新日志失败: {}", err))?;
let script_content =
build_windows_update_script(&installer, &current_exe, current_pid, &log_path);
fs::write(&script_path, script_content.as_bytes())
.map_err(|err| format!("写入更新脚本失败: {}", err))?;
let mut updater_child =
spawn_windows_update_script(&script_path, &temp_dir).map_err(|err| {
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| {
writeln!(
file,
"[bootstrap] failed to spawn updater powershell: {}",
err
)?;
Ok(())
});
format!("启动静默更新流程失败: {}", err)
})?;
let updater_pid = updater_child.id();
// A successful CreateProcess call does not prove that PowerShell parsed and
// started the script. Catch immediate startup failures before closing the app.
std::thread::sleep(Duration::from_millis(300));
let early_exit = updater_child
.try_wait()
.map_err(|err| format!("检查静默更新进程失败: {}", err))?;
if let Some(status) = early_exit {
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| {
writeln!(
file,
"[bootstrap] updater powershell exited before handoff: {}",
status
)?;
Ok(())
});
return Err(format!("静默更新进程启动后立即退出: {}", status));
}
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| {
writeln!(
file,
"[bootstrap] updater powershell running: pid={}",
updater_pid
)?;
Ok(())
});
let mut cleanup_entries: Vec<PathBuf> = fs::read_dir(&temp_dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(Result::ok))
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("silent-update-"))
.unwrap_or(false)
})
.collect();
cleanup_entries.sort_by_key(|path| {
fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH)
});
if cleanup_entries.len() > 24 {
let remove_count = cleanup_entries.len().saturating_sub(24);
for stale_path in cleanup_entries.into_iter().take(remove_count) {
let _ = fs::remove_file(stale_path);
}
}
}
#[cfg(not(target_os = "windows"))]
{
#[cfg(target_os = "macos")]
let spawn_result = Command::new("open").arg(&installer).spawn();
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let spawn_result = Command::new("xdg-open").arg(&installer).spawn();
spawn_result.map_err(|err| format!("启动安装程序失败: {}", err))?;
}
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert(
"message".to_string(),
Value::String("静默安装流程已启动,安装完成后将自动重启".to_string()),
);
data.insert("installerPath".to_string(), Value::String(path_text));
#[cfg(target_os = "windows")]
{
data.insert(
"logPath".to_string(),
Value::String(
env::temp_dir()
.join("wanwan-cloud-desktop")
.to_string_lossy()
.to_string(),
),
);
data.insert(
"logFilePath".to_string(),
Value::String(windows_log_file_path),
);
data.insert(
"scriptPath".to_string(),
Value::String(windows_script_file_path),
);
}
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
async fn api_check_client_update(
state: tauri::State<'_, ApiState>,
base_url: String,
current_version: String,
platform: Option<String>,
channel: Option<String>,
) -> Result<BridgeResponse, String> {
let normalized_platform = platform
.unwrap_or_else(|| "windows-x64".to_string())
.trim()
.to_string();
let normalized_channel = channel
.unwrap_or_else(|| "stable".to_string())
.trim()
.to_string();
let api_url = format!(
"{}?currentVersion={}&platform={}&channel={}",
join_api_url(&base_url, "/api/client/desktop-update"),
urlencoding::encode(current_version.trim()),
urlencoding::encode(&normalized_platform),
urlencoding::encode(&normalized_channel)
);
request_json(&state.client, Method::GET, api_url, None, None).await
}
#[tauri::command]
async fn api_list_local_files(dir_path: String) -> Result<BridgeResponse, String> {
let trimmed = dir_path.trim().to_string();
if trimmed.is_empty() {
return Err("本地目录不能为空".to_string());
}
let root = PathBuf::from(&trimmed);
if !root.exists() {
return Err("本地目录不存在".to_string());
}
if !root.is_dir() {
return Err("请选择有效的目录路径".to_string());
}
let mut items: Vec<Value> = Vec::new();
for entry in walkdir::WalkDir::new(&root)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let full_path = entry.path();
let relative = full_path.strip_prefix(&root).unwrap_or(full_path);
let relative_path = relative.to_string_lossy().replace('\\', "/");
if relative_path.trim().is_empty() {
continue;
}
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(_) => continue,
};
let modified_ms_u128 = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or(0);
let modified_ms = std::cmp::min(modified_ms_u128, u128::from(u64::MAX)) as u64;
let mut row = Map::new();
row.insert(
"path".to_string(),
Value::String(full_path.to_string_lossy().to_string()),
);
row.insert("relativePath".to_string(), Value::String(relative_path));
row.insert(
"size".to_string(),
Value::Number(serde_json::Number::from(metadata.len())),
);
row.insert(
"modifiedMs".to_string(),
Value::Number(serde_json::Number::from(modified_ms)),
);
items.push(Value::Object(row));
}
items.sort_by(|a, b| {
let av = a
.get("relativePath")
.and_then(Value::as_str)
.unwrap_or_default();
let bv = b
.get("relativePath")
.and_then(Value::as_str)
.unwrap_or_default();
av.cmp(bv)
});
let mut data = Map::new();
data.insert("success".to_string(), Value::Bool(true));
data.insert("rootPath".to_string(), Value::String(trimmed));
data.insert(
"count".to_string(),
Value::Number(serde_json::Number::from(items.len() as u64)),
);
data.insert("items".to_string(), Value::Array(items));
Ok(BridgeResponse {
ok: true,
status: 200,
data: Value::Object(data),
})
}
#[tauri::command]
async fn api_upload_file_resumable(
state: tauri::State<'_, ApiState>,
window: tauri::WebviewWindow,
base_url: String,
file_path: String,
target_path: String,
chunk_size: Option<u64>,
task_id: Option<String>,
) -> Result<BridgeResponse, String> {
let trimmed_path = file_path.trim().to_string();
if trimmed_path.is_empty() {
return Err("上传文件路径不能为空".to_string());
}
let source_path = PathBuf::from(&trimmed_path);
if !source_path.exists() {
return Err("上传文件不存在".to_string());
}
if !source_path.is_file() {
return Err("仅支持上传文件,不支持文件夹".to_string());
}
let metadata =
fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
let file_size = metadata.len();
if file_size == 0 {
return Err("空文件不支持分片上传".to_string());
}
let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?));
let file_name = source_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
.ok_or_else(|| "无法识别文件名".to_string())?;
let normalized_target = if target_path.trim().is_empty() {
"/".to_string()
} else {
target_path
};
let effective_chunk = chunk_size
.unwrap_or(4 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
let mut init_body = Map::new();
init_body.insert("filename".to_string(), Value::String(file_name.clone()));
init_body.insert("path".to_string(), Value::String(normalized_target));
init_body.insert(
"size".to_string(),
Value::Number(serde_json::Number::from(file_size)),
);
init_body.insert(
"chunk_size".to_string(),
Value::Number(serde_json::Number::from(effective_chunk)),
);
if let Some(hash) = file_fingerprint.clone() {
init_body.insert("file_hash".to_string(), Value::String(hash));
}
let init_resp = request_json(
&state.client,
Method::POST,
join_api_url(&base_url, "/api/upload/resumable/init"),
Some(Value::Object(init_body)),
csrf_token.clone(),
)
.await?;
if !init_resp.ok
|| !init_resp
.data
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(init_resp);
}
let session_id = init_resp
.data
.get("session_id")
.and_then(Value::as_str)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.ok_or_else(|| "分片上传会话创建失败".to_string())?;
let server_chunk_size = init_resp
.data
.get("chunk_size")
.and_then(Value::as_u64)
.unwrap_or(effective_chunk)
.max(1);
let total_chunks = init_resp
.data
.get("total_chunks")
.and_then(Value::as_u64)
.unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1));
let uploaded_chunks: std::collections::HashSet<u64> = init_resp
.data
.get("uploaded_chunks")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_u64)
.collect::<std::collections::HashSet<u64>>()
})
.unwrap_or_default();
let mut uploaded_bytes = uploaded_chunks.iter().fold(0_u64, |sum, chunk_index| {
let offset = chunk_index.saturating_mul(server_chunk_size);
let remaining = file_size.saturating_sub(offset);
let bytes = std::cmp::min(remaining, server_chunk_size);
sum.saturating_add(bytes)
});
if let Some(ref id) = task_id {
emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false);
}
let mut source =
fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
let mut last_emit = Instant::now();
for chunk_index in 0..total_chunks {
if uploaded_chunks.contains(&chunk_index) {
continue;
}
let offset = chunk_index * server_chunk_size;
let remaining = file_size.saturating_sub(offset);
if remaining == 0 {
break;
}
let read_size = std::cmp::min(remaining, server_chunk_size) as usize;
source
.seek(SeekFrom::Start(offset))
.map_err(|err| format!("读取分片失败: {}", err))?;
let mut buf = vec![0_u8; read_size];
source
.read_exact(&mut buf)
.map_err(|err| format!("读取分片失败: {}", err))?;
let chunk_part_name = format!("{}.part{}", file_name, chunk_index);
let mut chunk_done = false;
for attempt in 0..=RESUMABLE_CHUNK_MAX_RETRIES {
let multipart = reqwest::multipart::Form::new()
.text("session_id", session_id.clone())
.text("chunk_index", chunk_index.to_string())
.part(
"chunk",
reqwest::multipart::Part::bytes(buf.clone()).file_name(chunk_part_name.clone()),
);
let mut request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(60 * 10))
.multipart(multipart);
if let Some(token) = csrf_token.clone() {
request = request.header("X-CSRF-Token", token);
}
let chunk_bridge = match request.send().await {
Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?,
Err(err) => {
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&err) {
sleep(build_chunk_retry_delay(attempt)).await;
continue;
}
return Err(format!("上传分片失败: {}", err));
}
};
let chunk_success = chunk_bridge.ok
&& chunk_bridge
.data
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false);
if chunk_success {
chunk_done = true;
break;
}
let message = chunk_bridge
.data
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let retryable_status = is_retryable_upload_status(chunk_bridge.status);
let retryable_message = message.contains("超时")
|| message.to_lowercase().contains("timeout")
|| message.contains("稍后重试");
if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) {
sleep(build_chunk_retry_delay(attempt)).await;
continue;
}
return Ok(chunk_bridge);
}
if !chunk_done {
return Err("上传分片失败,请重试".to_string());
}
uploaded_bytes = uploaded_bytes
.saturating_add(read_size as u64)
.min(file_size);
if let Some(ref id) = task_id {
if last_emit.elapsed() >= Duration::from_millis(120) {
emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false);
last_emit = Instant::now();
}
}
}
let mut complete_body = Map::new();
complete_body.insert("session_id".to_string(), Value::String(session_id));
let mut complete_request = state
.client
.post(join_api_url(&base_url, "/api/upload/resumable/complete"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60 * 20))
.json(&Value::Object(complete_body));
if let Some(token) = csrf_token {
complete_request = complete_request.header("X-CSRF-Token", token);
}
let complete_raw = complete_request
.send()
.await
.map_err(|err| format!("完成分片上传失败: {}", err))?;
let complete_resp = parse_response_as_bridge(complete_raw).await?;
if complete_resp.ok
&& complete_resp
.data
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(ref id) = task_id {
emit_native_upload_progress(&window, id, file_size, file_size, true);
}
}
Ok(complete_resp)
}
#[tauri::command]
async fn api_upload_file(
state: tauri::State<'_, ApiState>,
window: tauri::WebviewWindow,
base_url: String,
file_path: String,
target_path: String,
task_id: Option<String>,
) -> Result<BridgeResponse, String> {
let trimmed_path = file_path.trim().to_string();
if trimmed_path.is_empty() {
return Err("上传文件路径不能为空".to_string());
}
let source_path = PathBuf::from(trimmed_path);
if !source_path.exists() {
return Err("上传文件不存在".to_string());
}
if !source_path.is_file() {
return Err("仅支持上传文件,不支持文件夹".to_string());
}
let file_meta =
fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
let file_size = file_meta.len();
let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?));
let file_name = source_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
.ok_or_else(|| "无法识别文件名".to_string())?;
let normalized_target = if target_path.trim().is_empty() {
"/".to_string()
} else {
target_path
};
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
let upload_url = join_api_url(&base_url, "/api/upload");
if upload_url.trim().is_empty() {
return Err("API 地址不能为空".to_string());
}
if let Some(ref id) = task_id {
emit_native_upload_progress(&window, id, 0, file_size.max(1), false);
}
// 使用流式 multipart 上传,避免大文件整块读入内存导致占用暴涨。
let file_part = reqwest::multipart::Part::file(&source_path)
.await
.map_err(|err| format!("读取文件失败: {}", err))?
.file_name(file_name);
let mut multipart = reqwest::multipart::Form::new()
.text("path", normalized_target)
.part("file", file_part);
if let Some(hash) = file_fingerprint {
multipart = multipart.text("file_hash", hash);
}
let mut request = state
.client
.post(&upload_url)
.header("Accept", "application/json")
.timeout(Duration::from_secs(60 * 30))
.multipart(multipart);
if let Some(csrf) = csrf_token {
request = request.header("X-CSRF-Token", csrf);
}
let response = request
.send()
.await
.map_err(|err| format!("上传请求失败: {}", err))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取响应失败: {}", err))?;
let data = match serde_json::from_str::<Value>(&text) {
Ok(parsed) => parsed,
Err(_) => fallback_json(status, &text),
};
let success = status.is_success()
&& data
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false);
if success {
if let Some(ref id) = task_id {
emit_native_upload_progress(&window, id, file_size, file_size.max(1), true);
}
}
Ok(BridgeResponse {
ok: status.is_success(),
status: status.as_u16(),
data,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let client = reqwest::Client::builder()
.cookie_store(true)
.timeout(Duration::from_secs(90))
.build()
.expect("failed to build reqwest client");
tauri::Builder::default()
.manage(ApiState { client })
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
api_login,
api_save_login_state,
api_load_login_state,
api_clear_login_state,
api_get_profile,
api_list_online_devices,
api_kick_online_device,
api_list_files,
api_logout,
api_refresh_token,
api_search_files,
api_mkdir,
api_rename_file,
api_delete_file,
api_get_download_url,
api_get_my_shares,
api_get_my_direct_links,
api_delete_direct_link,
api_create_share,
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,
api_list_local_files,
api_upload_file_resumable,
api_upload_file
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}