feat: 导入 CapacityReport 初始源码
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "capacity-report-desktop"
|
||||
version = "3.0.0"
|
||||
description = "CapacityReport desktop shell"
|
||||
authors = ["NIXEVOL"]
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
open = "5.3.5"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sysinfo = { version = "0.38.4", default-features = false, features = ["system"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default desktop permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:webview:deny-internal-toggle-devtools"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","core:webview:deny-internal-toggle-devtools"]}}
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,366 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sysinfo::{Pid, ProcessesToUpdate, Signal, System};
|
||||
use tauri::{Manager, Runtime};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
const SERVER_PORT: u16 = 9081;
|
||||
const SERVER_ADDR: &str = "127.0.0.1:9081";
|
||||
const HTTP_ERROR_PREFIX: &str = "CAPAREPORT_HTTP_ERROR:";
|
||||
const SERVER_PROCESS_NAME: &str = "capareport-server";
|
||||
const SERVER_PID_FILE: &str = "server.pid";
|
||||
|
||||
struct ServerProcess {
|
||||
child: CommandChild,
|
||||
pid_file: PathBuf,
|
||||
}
|
||||
|
||||
struct ServerState(Mutex<Option<ServerProcess>>);
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DownloadHeader {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DownloadFileRequest {
|
||||
url: String,
|
||||
method: String,
|
||||
filename: String,
|
||||
headers: Vec<DownloadHeader>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DownloadFileResult {
|
||||
saved: bool,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
fn copy_resource<R: Runtime>(
|
||||
app: &tauri::App<R>,
|
||||
name: &str,
|
||||
data_dir: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let target = data_dir.join(name);
|
||||
if target.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for source in resource_candidates(app, name)? {
|
||||
if source.exists() {
|
||||
fs::copy(source, target)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Bundled resource not found: {name}").into())
|
||||
}
|
||||
|
||||
fn resource_candidates<R: Runtime>(
|
||||
app: &tauri::App<R>,
|
||||
name: &str,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
let resource_path = app
|
||||
.path()
|
||||
.resolve(name, tauri::path::BaseDirectory::Resource)?;
|
||||
let escaped_parent_path = app
|
||||
.path()
|
||||
.resolve(format!("_up_/{name}"), tauri::path::BaseDirectory::Resource)?;
|
||||
let exe_dir = std::env::current_exe()?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or("Unable to resolve executable directory")?;
|
||||
|
||||
Ok(vec![
|
||||
resource_path,
|
||||
escaped_parent_path,
|
||||
exe_dir.join(name),
|
||||
exe_dir.join("_up_").join(name),
|
||||
])
|
||||
}
|
||||
|
||||
fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
fs::create_dir_all(data_dir.join("cache"))?;
|
||||
fs::create_dir_all(data_dir.join("logs"))?;
|
||||
copy_resource(app, "Configure.json", &data_dir)?;
|
||||
copy_resource(app, "ReportScript.sql", &data_dir)?;
|
||||
let pid_file = data_dir.join(SERVER_PID_FILE);
|
||||
stop_recorded_server(&pid_file);
|
||||
ensure_server_port_available(SERVER_ADDR)?;
|
||||
|
||||
let port_arg = SERVER_PORT.to_string();
|
||||
let (mut rx, child) = app
|
||||
.shell()
|
||||
.sidecar("capareport-server")?
|
||||
.env(
|
||||
"CAPAREPORT_BASE_DIR",
|
||||
data_dir.to_string_lossy().to_string(),
|
||||
)
|
||||
.args(["--host", "127.0.0.1", "--port", &port_arg])
|
||||
.spawn()?;
|
||||
let pid = child.pid();
|
||||
fs::write(&pid_file, pid.to_string())?;
|
||||
|
||||
if let Err(error) = wait_for_server(SERVER_ADDR, Duration::from_secs(60)) {
|
||||
let _ = child.kill();
|
||||
let _ = fs::remove_file(&pid_file);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} });
|
||||
|
||||
let state = app.state::<ServerState>();
|
||||
*state.0.lock().expect("server state lock poisoned") = Some(ServerProcess { child, pid_file });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_server_port_available(addr: &str) -> Result<(), Box<dyn Error>> {
|
||||
let socket_addr: SocketAddr = addr.parse()?;
|
||||
if TcpStream::connect_timeout(&socket_addr, Duration::from_millis(300)).is_ok() {
|
||||
return Err(format!("Port {SERVER_PORT} is already in use").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_server(addr: &str, timeout: Duration) -> Result<(), Box<dyn Error>> {
|
||||
let socket_addr: SocketAddr = addr.parse()?;
|
||||
let started_at = Instant::now();
|
||||
while started_at.elapsed() < timeout {
|
||||
if health_check(&socket_addr).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
Err(format!("Server did not start within {} seconds", timeout.as_secs()).into())
|
||||
}
|
||||
|
||||
fn health_check(socket_addr: &SocketAddr) -> Result<bool, Box<dyn Error>> {
|
||||
let mut stream = TcpStream::connect_timeout(socket_addr, Duration::from_millis(500))?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(500)))?;
|
||||
stream.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response)?;
|
||||
Ok(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200"))
|
||||
}
|
||||
|
||||
fn stop_recorded_server(pid_file: &Path) {
|
||||
let Ok(content) = fs::read_to_string(pid_file) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if !terminate_server_process(pid) {
|
||||
let _ = fs::remove_file(pid_file);
|
||||
return;
|
||||
}
|
||||
wait_for_process_exit(pid, Duration::from_secs(5));
|
||||
}
|
||||
let _ = fs::remove_file(pid_file);
|
||||
}
|
||||
|
||||
fn terminate_server_process(pid: u32) -> bool {
|
||||
let mut system = System::new();
|
||||
let pid = Pid::from_u32(pid);
|
||||
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
|
||||
let Some(process) = system.process(pid) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !is_server_process_name(process.name()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if process.kill_with(Signal::Kill) != Some(true) {
|
||||
let _ = process.kill();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn wait_for_process_exit(pid: u32, timeout: Duration) {
|
||||
let started_at = Instant::now();
|
||||
while started_at.elapsed() < timeout {
|
||||
if !is_process_running(pid) {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_process_running(pid: u32) -> bool {
|
||||
let mut system = System::new();
|
||||
let pid = Pid::from_u32(pid);
|
||||
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
system.process(pid).is_some()
|
||||
}
|
||||
|
||||
fn is_server_process_name(name: &std::ffi::OsStr) -> bool {
|
||||
let normalized = name.to_string_lossy().to_ascii_lowercase();
|
||||
normalized == SERVER_PROCESS_NAME || normalized == format!("{SERVER_PROCESS_NAME}.exe")
|
||||
}
|
||||
|
||||
fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) {
|
||||
let state = app.state::<ServerState>();
|
||||
let child = state.0.lock().expect("server state lock poisoned").take();
|
||||
if let Some(process) = child {
|
||||
let _ = process.child.kill();
|
||||
let _ = fs::remove_file(process.pid_file);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn download_to_file(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
request: DownloadFileRequest,
|
||||
) -> Result<DownloadFileResult, String> {
|
||||
let filename = safe_download_filename(&request.filename);
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_file_name(&filename)
|
||||
.set_parent(&window);
|
||||
|
||||
if let Some(extension) = Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension]);
|
||||
}
|
||||
|
||||
let Some(file_path) = dialog
|
||||
.blocking_save_file()
|
||||
.map(|file_path| file_path.into_path().map_err(|error| error.to_string()))
|
||||
.transpose()?
|
||||
else {
|
||||
return Ok(DownloadFileResult {
|
||||
saved: false,
|
||||
path: None,
|
||||
});
|
||||
};
|
||||
|
||||
if let Err(error) = write_download(request, &file_path).await {
|
||||
let _ = fs::remove_file(&file_path);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(DownloadFileResult {
|
||||
saved: true,
|
||||
path: Some(file_path.to_string_lossy().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path_in_file_manager(path: String) -> Result<(), String> {
|
||||
let target = PathBuf::from(path);
|
||||
let directory = if target.is_dir() {
|
||||
target
|
||||
} else {
|
||||
target
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "Unable to resolve download directory".to_string())?
|
||||
};
|
||||
|
||||
open::that(directory).map_err(|error| format!("Unable to open download directory: {error}"))
|
||||
}
|
||||
|
||||
async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> {
|
||||
let method = reqwest::Method::from_bytes(request.method.as_bytes())
|
||||
.map_err(|error| format!("Invalid download method: {error}"))?;
|
||||
let client = reqwest::Client::new();
|
||||
let mut builder = client.request(method, request.url);
|
||||
|
||||
for header in request.headers {
|
||||
let name = reqwest::header::HeaderName::from_bytes(header.name.as_bytes())
|
||||
.map_err(|error| format!("Invalid download header: {error}"))?;
|
||||
let value = reqwest::header::HeaderValue::from_str(&header.value)
|
||||
.map_err(|error| format!("Invalid download header value: {error}"))?;
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(body) = request.body {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let mut response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Download request failed: {error}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
.unwrap_or_default();
|
||||
return Err(format!("{HTTP_ERROR_PREFIX}{}:{body}", status.as_u16()));
|
||||
}
|
||||
|
||||
let mut file = fs::File::create(file_path)
|
||||
.map_err(|error| format!("Unable to create download file: {error}"))?;
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("Unable to read download stream: {error}"))?
|
||||
{
|
||||
file.write_all(&chunk)
|
||||
.map_err(|error| format!("Unable to write download file: {error}"))?;
|
||||
}
|
||||
file.flush()
|
||||
.map_err(|error| format!("Unable to flush download file: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_download_filename(filename: &str) -> String {
|
||||
let trimmed = filename.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "download.bin".to_string();
|
||||
}
|
||||
|
||||
Path::new(trimmed)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("download.bin")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
download_to_file,
|
||||
open_path_in_file_manager
|
||||
])
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.setup(|app| {
|
||||
start_server(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
stop_server(window.app_handle());
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CapacityReport",
|
||||
"version": "3.0.0",
|
||||
"identifier": "com.nixevol.capacityreport",
|
||||
"build": {
|
||||
"frontendDist": "../frontend/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CapacityReport",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/capareport-server"
|
||||
],
|
||||
"resources": [
|
||||
"../Configure.json",
|
||||
"../ReportScript.sql"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "offlineInstaller",
|
||||
"silent": true
|
||||
},
|
||||
"wix": {
|
||||
"language": "zh-CN"
|
||||
},
|
||||
"nsis": {
|
||||
"installerHooks": "./windows/nsis-hooks.nsh",
|
||||
"installMode": "perMachine",
|
||||
"languages": [
|
||||
"SimpChinese"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
!macro ResolveCapacityReportInstallDir
|
||||
${If} ${FileExists} "D:\*.*"
|
||||
StrCpy $R9 "D:\Program Files\${PRODUCTNAME}"
|
||||
${Else}
|
||||
${If} ${RunningX64}
|
||||
StrCpy $R9 "$PROGRAMFILES64\${PRODUCTNAME}"
|
||||
${Else}
|
||||
StrCpy $R9 "$PROGRAMFILES\${PRODUCTNAME}"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
!insertmacro ResolveCapacityReportInstallDir
|
||||
StrCpy $INSTDIR "$R9"
|
||||
SetOutPath "$INSTDIR"
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}"
|
||||
DeleteRegKey HKCU "Software\${MANUFACTURER}\${PRODUCTNAME}"
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM capacity-report-desktop.exe'
|
||||
nsExec::ExecToLog 'taskkill /F /T /IM capareport-server.exe'
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "是否同时删除本机用户数据?" IDYES DeleteUserData IDNO Done
|
||||
DeleteUserData:
|
||||
RMDir /r "$APPDATA\com.nixevol.capacityreport"
|
||||
Done:
|
||||
!macroend
|
||||