|
|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
use reqwest::{Method, Url};
|
|
|
|
|
use reqwest::StatusCode;
|
|
|
|
|
use reqwest::{Method, Url};
|
|
|
|
|
use rusqlite::{params, Connection};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use serde_json::{Map, Value};
|
|
|
|
|
@@ -18,19 +18,15 @@ 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")]
|
|
|
|
|
use windows_sys::Win32::Foundation::LocalFree;
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
|
|
|
|
const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3;
|
|
|
|
|
const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900;
|
|
|
|
|
|
|
|
|
|
@@ -182,22 +178,6 @@ fn build_desktop_client_meta() -> (String, String, String) {
|
|
|
|
|
(platform, device_name, device_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option<String> {
|
|
|
|
|
let size = meta.len();
|
|
|
|
|
let modified_ms = meta
|
|
|
|
|
.modified()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
|
|
|
|
|
.map(|duration| duration.as_millis())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms);
|
|
|
|
|
if fingerprint.len() > 120 {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(fingerprint)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_retryable_upload_status(status: u16) -> bool {
|
|
|
|
|
matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504)
|
|
|
|
|
}
|
|
|
|
|
@@ -208,7 +188,9 @@ fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS
|
|
|
|
|
.saturating_mul(multiplier)
|
|
|
|
|
.min(15_000);
|
|
|
|
|
Duration::from_millis(ms)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -263,7 +245,10 @@ fn dpapi_protect_bytes(input: &[u8]) -> Result<Vec<u8>, String> {
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
if ok == 0 {
|
|
|
|
|
return Err(format!("加密登录状态失败: {}", std::io::Error::last_os_error()));
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"加密登录状态失败: {}",
|
|
|
|
|
std::io::Error::last_os_error()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data = unsafe {
|
|
|
|
|
@@ -302,7 +287,10 @@ fn dpapi_unprotect_bytes(input: &[u8]) -> Result<Vec<u8>, String> {
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
if ok == 0 {
|
|
|
|
|
return Err(format!("解密登录状态失败: {}", std::io::Error::last_os_error()));
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"解密登录状态失败: {}",
|
|
|
|
|
std::io::Error::last_os_error()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data = unsafe {
|
|
|
|
|
@@ -341,8 +329,8 @@ fn decode_login_password(stored_password: &str) -> Result<String, String> {
|
|
|
|
|
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())?;
|
|
|
|
|
let value =
|
|
|
|
|
u8::from_str_radix(part, 16).map_err(|_| "登录状态密文格式无效".to_string())?;
|
|
|
|
|
encrypted.push(value);
|
|
|
|
|
index += 2;
|
|
|
|
|
}
|
|
|
|
|
@@ -377,7 +365,10 @@ 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') {
|
|
|
|
|
if matches!(
|
|
|
|
|
ch,
|
|
|
|
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
|
|
|
|
|
) {
|
|
|
|
|
cleaned.push('_');
|
|
|
|
|
} else {
|
|
|
|
|
cleaned.push(ch);
|
|
|
|
|
@@ -435,11 +426,25 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
|
|
|
|
|
first
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf {
|
|
|
|
|
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);
|
|
|
|
|
url.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);
|
|
|
|
|
@@ -504,7 +509,8 @@ fn cleanup_old_update_installers(
|
|
|
|
|
) -> 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))? {
|
|
|
|
|
for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))?
|
|
|
|
|
{
|
|
|
|
|
let path = match entry {
|
|
|
|
|
Ok(item) => item.path(),
|
|
|
|
|
Err(_) => continue,
|
|
|
|
|
@@ -544,6 +550,163 @@ fn cleanup_old_update_installers(
|
|
|
|
|
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");
|
|
|
|
|
@@ -558,7 +721,8 @@ 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))?;
|
|
|
|
|
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),
|
|
|
|
|
@@ -608,16 +772,14 @@ fn load_login_state_record() -> Result<Option<(String, String, String)>, String>
|
|
|
|
|
});
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
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)),
|
|
|
|
|
}
|
|
|
|
|
@@ -694,7 +856,10 @@ async fn request_json(
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn fetch_csrf_token(client: &reqwest::Client, base_url: &str) -> Result<Option<String>, String> {
|
|
|
|
|
async fn fetch_csrf_token(
|
|
|
|
|
client: &reqwest::Client,
|
|
|
|
|
base_url: &str,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
|
|
|
|
let response = request_json(
|
|
|
|
|
client,
|
|
|
|
|
Method::GET,
|
|
|
|
|
@@ -749,7 +914,10 @@ async fn api_login(
|
|
|
|
|
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(
|
|
|
|
|
"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));
|
|
|
|
|
@@ -852,7 +1020,10 @@ fn api_save_login_state(
|
|
|
|
|
|
|
|
|
|
let mut data = Map::new();
|
|
|
|
|
data.insert("success".to_string(), Value::Bool(true));
|
|
|
|
|
data.insert("message".to_string(), Value::String("登录状态已保存".to_string()));
|
|
|
|
|
data.insert(
|
|
|
|
|
"message".to_string(),
|
|
|
|
|
Value::String("登录状态已保存".to_string()),
|
|
|
|
|
);
|
|
|
|
|
Ok(BridgeResponse {
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
@@ -886,7 +1057,10 @@ 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()));
|
|
|
|
|
data.insert(
|
|
|
|
|
"message".to_string(),
|
|
|
|
|
Value::String("登录状态已清除".to_string()),
|
|
|
|
|
);
|
|
|
|
|
Ok(BridgeResponse {
|
|
|
|
|
ok: true,
|
|
|
|
|
status: 200,
|
|
|
|
|
@@ -1122,6 +1296,11 @@ async fn api_create_share(
|
|
|
|
|
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));
|
|
|
|
|
@@ -1129,7 +1308,10 @@ async fn api_create_share(
|
|
|
|
|
|
|
|
|
|
if let Some(name) = file_name {
|
|
|
|
|
if !name.trim().is_empty() {
|
|
|
|
|
body.insert("file_name".to_string(), Value::String(name.trim().to_string()));
|
|
|
|
|
body.insert(
|
|
|
|
|
"file_name".to_string(),
|
|
|
|
|
Value::String(name.trim().to_string()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1152,6 +1334,52 @@ async fn api_create_share(
|
|
|
|
|
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,
|
|
|
|
|
@@ -1198,7 +1426,10 @@ async fn api_create_direct_link(
|
|
|
|
|
|
|
|
|
|
if let Some(name) = file_name {
|
|
|
|
|
if !name.trim().is_empty() {
|
|
|
|
|
body.insert("file_name".to_string(), Value::String(name.trim().to_string()));
|
|
|
|
|
body.insert(
|
|
|
|
|
"file_name".to_string(),
|
|
|
|
|
Value::String(name.trim().to_string()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1245,11 +1476,11 @@ async fn api_native_download(
|
|
|
|
|
|
|
|
|
|
let download_dir = resolve_download_dir();
|
|
|
|
|
if !download_dir.exists() {
|
|
|
|
|
fs::create_dir_all(&download_dir)
|
|
|
|
|
.map_err(|err| format!("创建下载目录失败: {}", err))?;
|
|
|
|
|
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 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()
|
|
|
|
|
@@ -1271,6 +1502,16 @@ async fn api_native_download(
|
|
|
|
|
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))?;
|
|
|
|
|
@@ -1416,7 +1657,11 @@ async fn api_native_download(
|
|
|
|
|
);
|
|
|
|
|
data.insert(
|
|
|
|
|
"resumedBytes".to_string(),
|
|
|
|
|
Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })),
|
|
|
|
|
Value::Number(serde_json::Number::from(if append_mode {
|
|
|
|
|
existing_size
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(BridgeResponse {
|
|
|
|
|
@@ -1478,7 +1723,10 @@ fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String
|
|
|
|
|
|
|
|
|
|
let mut data = Map::new();
|
|
|
|
|
data.insert("success".to_string(), Value::Bool(true));
|
|
|
|
|
data.insert("message".to_string(), Value::String("安装程序已启动".to_string()));
|
|
|
|
|
data.insert(
|
|
|
|
|
"message".to_string(),
|
|
|
|
|
Value::String("安装程序已启动".to_string()),
|
|
|
|
|
);
|
|
|
|
|
data.insert("installerPath".to_string(), Value::String(path_text));
|
|
|
|
|
|
|
|
|
|
Ok(BridgeResponse {
|
|
|
|
|
@@ -1500,7 +1748,8 @@ fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeRespon
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
{
|
|
|
|
|
let current_exe = env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?;
|
|
|
|
|
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))?;
|
|
|
|
|
@@ -1508,89 +1757,75 @@ fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeRespon
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.map(|duration| duration.as_millis())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let script_path = temp_dir.join(format!("silent-update-{}.cmd", script_stamp));
|
|
|
|
|
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 installer_text = installer.to_string_lossy().replace('"', "\"\"");
|
|
|
|
|
let app_text = current_exe.to_string_lossy().replace('"', "\"\"");
|
|
|
|
|
let log_text = log_path.to_string_lossy().replace('"', "\"\"");
|
|
|
|
|
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 = format!(
|
|
|
|
|
"@echo off\r\n\
|
|
|
|
|
setlocal enableextensions\r\n\
|
|
|
|
|
set \"INSTALLER={installer}\"\r\n\
|
|
|
|
|
set \"APP_EXE={app_exe}\"\r\n\
|
|
|
|
|
set \"APP_PID={app_pid}\"\r\n\
|
|
|
|
|
set \"LOG_FILE={log_file}\"\r\n\
|
|
|
|
|
echo [%%date%% %%time%%] update script started > \"%LOG_FILE%\"\r\n\
|
|
|
|
|
if not exist \"%INSTALLER%\" (\r\n\
|
|
|
|
|
echo [%%date%% %%time%%] installer not found: %INSTALLER% >> \"%LOG_FILE%\"\r\n\
|
|
|
|
|
exit /b 2\r\n\
|
|
|
|
|
)\r\n\
|
|
|
|
|
timeout /t 1 /nobreak >nul\r\n\
|
|
|
|
|
taskkill /PID %APP_PID% /F >nul 2>nul\r\n\
|
|
|
|
|
timeout /t 1 /nobreak >nul\r\n\
|
|
|
|
|
start \"\" /wait \"%INSTALLER%\" /S\r\n\
|
|
|
|
|
set \"INSTALL_EXIT=%ERRORLEVEL%\"\r\n\
|
|
|
|
|
echo [%%date%% %%time%%] installer exit code: %INSTALL_EXIT% >> \"%LOG_FILE%\"\r\n\
|
|
|
|
|
set \"RETRY_COUNT=0\"\r\n\
|
|
|
|
|
:wait_for_app\r\n\
|
|
|
|
|
if exist \"%APP_EXE%\" goto launch_app\r\n\
|
|
|
|
|
if exist \"%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\" (\r\n\
|
|
|
|
|
set \"APP_EXE=%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\"\r\n\
|
|
|
|
|
goto launch_app\r\n\
|
|
|
|
|
)\r\n\
|
|
|
|
|
if %RETRY_COUNT% GEQ 25 goto app_missing\r\n\
|
|
|
|
|
set /a RETRY_COUNT+=1\r\n\
|
|
|
|
|
timeout /t 1 /nobreak >nul\r\n\
|
|
|
|
|
goto wait_for_app\r\n\
|
|
|
|
|
:launch_app\r\n\
|
|
|
|
|
start \"\" \"%APP_EXE%\"\r\n\
|
|
|
|
|
set \"START_EXIT=%ERRORLEVEL%\"\r\n\
|
|
|
|
|
echo [%%date%% %%time%%] launch app exit code: %START_EXIT% path=%APP_EXE% >> \"%LOG_FILE%\"\r\n\
|
|
|
|
|
goto cleanup\r\n\
|
|
|
|
|
:app_missing\r\n\
|
|
|
|
|
echo [%%date%% %%time%%] app exe not found after install >> \"%LOG_FILE%\"\r\n\
|
|
|
|
|
:cleanup\r\n\
|
|
|
|
|
del \"%~f0\" >nul 2>nul\r\n",
|
|
|
|
|
installer = installer_text,
|
|
|
|
|
app_exe = app_text,
|
|
|
|
|
app_pid = current_pid,
|
|
|
|
|
log_file = log_text
|
|
|
|
|
);
|
|
|
|
|
fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?;
|
|
|
|
|
fs::write(&log_path, bootstrap_content)
|
|
|
|
|
.map_err(|err| format!("写入更新日志失败: {}", err))?;
|
|
|
|
|
let script_content =
|
|
|
|
|
build_windows_update_script(&installer, ¤t_exe, current_pid, &log_path);
|
|
|
|
|
fs::write(&script_path, script_content.as_bytes())
|
|
|
|
|
.map_err(|err| format!("写入更新脚本失败: {}", err))?;
|
|
|
|
|
|
|
|
|
|
let mut updater_cmd = Command::new("cmd");
|
|
|
|
|
let spawn_result = updater_cmd
|
|
|
|
|
.arg("/D")
|
|
|
|
|
.arg("/C")
|
|
|
|
|
.arg("call")
|
|
|
|
|
.arg(&script_path)
|
|
|
|
|
.current_dir(&temp_dir)
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
|
|
|
|
.spawn();
|
|
|
|
|
if let Err(err) = spawn_result {
|
|
|
|
|
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] failed to spawn updater cmd: {}", err)?;
|
|
|
|
|
writeln!(
|
|
|
|
|
file,
|
|
|
|
|
"[bootstrap] updater powershell exited before handoff: {}",
|
|
|
|
|
status
|
|
|
|
|
)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
});
|
|
|
|
|
return Err(format!("启动静默更新流程失败: {}", err));
|
|
|
|
|
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()
|
|
|
|
|
@@ -1801,12 +2036,13 @@ async fn api_upload_file_resumable(
|
|
|
|
|
return Err("仅支持上传文件,不支持文件夹".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
|
|
|
|
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 = build_upload_file_fingerprint(&metadata);
|
|
|
|
|
let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?));
|
|
|
|
|
|
|
|
|
|
let file_name = source_path
|
|
|
|
|
.file_name()
|
|
|
|
|
@@ -1818,7 +2054,9 @@ async fn api_upload_file_resumable(
|
|
|
|
|
} else {
|
|
|
|
|
target_path
|
|
|
|
|
};
|
|
|
|
|
let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024);
|
|
|
|
|
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();
|
|
|
|
|
@@ -1845,7 +2083,13 @@ async fn api_upload_file_resumable(
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
|
|
|
|
if !init_resp.ok
|
|
|
|
|
|| !init_resp
|
|
|
|
|
.data
|
|
|
|
|
.get("success")
|
|
|
|
|
.and_then(Value::as_bool)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
{
|
|
|
|
|
return Ok(init_resp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1888,7 +2132,8 @@ async fn api_upload_file_resumable(
|
|
|
|
|
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 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) {
|
|
|
|
|
@@ -1973,7 +2218,9 @@ async fn api_upload_file_resumable(
|
|
|
|
|
return Err("上传分片失败,请重试".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size);
|
|
|
|
|
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);
|
|
|
|
|
@@ -2000,7 +2247,13 @@ async fn api_upload_file_resumable(
|
|
|
|
|
.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 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);
|
|
|
|
|
}
|
|
|
|
|
@@ -2030,9 +2283,10 @@ async fn api_upload_file(
|
|
|
|
|
if !source_path.is_file() {
|
|
|
|
|
return Err("仅支持上传文件,不支持文件夹".to_string());
|
|
|
|
|
}
|
|
|
|
|
let file_meta = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
|
|
|
|
let file_meta =
|
|
|
|
|
fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
|
|
|
|
let file_size = file_meta.len();
|
|
|
|
|
let file_fingerprint = build_upload_file_fingerprint(&file_meta);
|
|
|
|
|
let file_fingerprint = Some(format!("sha256:{}", compute_file_sha256_hex(&source_path)?));
|
|
|
|
|
|
|
|
|
|
let file_name = source_path
|
|
|
|
|
.file_name()
|
|
|
|
|
@@ -2093,7 +2347,11 @@ async fn api_upload_file(
|
|
|
|
|
Ok(parsed) => parsed,
|
|
|
|
|
Err(_) => fallback_json(status, &text),
|
|
|
|
|
};
|
|
|
|
|
let success = status.is_success() && data.get("success").and_then(Value::as_bool).unwrap_or(false);
|
|
|
|
|
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);
|
|
|
|
|
|