Files
CapacityReport/src-tauri/src/main.rs
T

367 lines
11 KiB
Rust

#![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");
}