diff --git a/.gitignore b/.gitignore index 15f12df..3ce9ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ venv/ uploads/ .flaskenv .env +target/ +Cargo.lock +*.exe diff --git a/desktop/.cargo/config.toml b/desktop/.cargo/config.toml new file mode 100644 index 0000000..33ae1dd --- /dev/null +++ b/desktop/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "x86_64-pc-windows-gnu" + +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 0000000..5a072e8 --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "temp-file-transfer-desktop" +version = "0.1.0" +edition = "2021" + +[dependencies] +eframe = "0.31" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart"] } +tokio = { version = "1", features = ["rt", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = 2 +lto = true +strip = true diff --git a/desktop/build.bat b/desktop/build.bat new file mode 100644 index 0000000..369f544 --- /dev/null +++ b/desktop/build.bat @@ -0,0 +1,6 @@ +@echo off +set PATH=C:\msys64\mingw64\bin;%PATH% +cargo build --release +echo. +echo Build complete: target\x86_64-pc-windows-gnu\release\temp-file-transfer-desktop.exe +pause diff --git a/desktop/src/main.rs b/desktop/src/main.rs new file mode 100644 index 0000000..a9e3440 --- /dev/null +++ b/desktop/src/main.rs @@ -0,0 +1,289 @@ +#![windows_subsystem = "windows"] + +use eframe::egui; +use reqwest::multipart; +use std::sync::mpsc; + +#[derive(serde::Deserialize)] +struct UploadResponse { + #[allow(dead_code)] + id: Option, + #[allow(dead_code)] + filename: Option, + share_url: Option, + error: Option, +} + +enum UploadResult { + Ok(String), + Err(String), +} + +struct App { + api_url: String, + status: String, + share_url: String, + upload_rx: Option>, + uploading: bool, +} + +impl App { + fn new() -> Self { + Self { + api_url: "https://xiaji-temp.duckdns.org/api/upload".to_owned(), + status: "就绪,拖放文件到下方区域上传".to_owned(), + share_url: String::new(), + upload_rx: None, + uploading: false, + } + } + + fn start_upload(&mut self, filepath: std::path::PathBuf, api_url: String) { + let (tx, rx) = mpsc::channel(); + self.upload_rx = Some(rx); + self.uploading = true; + + let filename = filepath + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + match rt { + Ok(rt) => { + let result = rt.block_on(async { + do_upload(&api_url, &filepath, &filename).await + }); + let _ = tx.send(result); + } + Err(e) => { + let _ = tx.send(UploadResult::Err(format!("启动运行时失败: {}", e))); + } + } + }); + } + + fn poll_result(&mut self) { + if let Some(rx) = &self.upload_rx { + if let Ok(result) = rx.try_recv() { + self.uploading = false; + self.upload_rx = None; + match result { + UploadResult::Ok(url) => { + self.status = "上传成功".to_owned(); + self.share_url = url; + } + UploadResult::Err(e) => { + self.status = format!("上传失败: {}", e); + self.share_url.clear(); + } + } + } + } + } +} + +async fn do_upload(api_url: &str, filepath: &std::path::Path, filename: &str) -> UploadResult { + let data = match std::fs::read(filepath) { + Ok(d) => d, + Err(e) => return UploadResult::Err(format!("读取文件失败: {}", e)), + }; + + let part = multipart::Part::bytes(data) + .file_name(filename.to_owned()) + .mime_str("application/octet-stream") + .unwrap_or_else(|_| multipart::Part::bytes(vec![]).file_name(filename.to_owned())); + + let form = multipart::Form::new() + .part("file", part) + .text("expiry", "24h"); + + let client = match reqwest::Client::builder().build() { + Ok(c) => c, + Err(e) => return UploadResult::Err(format!("创建客户端失败: {}", e)), + }; + + let resp = match client.post(api_url).multipart(form).send().await { + Ok(r) => r, + Err(e) => return UploadResult::Err(format!("请求失败: {}", e)), + }; + + let status = resp.status(); + let body = match resp.text().await { + Ok(b) => b, + Err(e) => return UploadResult::Err(format!("读取响应失败: {}", e)), + }; + + if !status.is_success() { + let err: UploadResponse = serde_json::from_str(&body).unwrap_or(UploadResponse { + id: None, + filename: None, + share_url: None, + error: Some(body), + }); + return UploadResult::Err(err.error.unwrap_or_else(|| "未知错误".to_owned())); + } + + let result: UploadResponse = match serde_json::from_str(&body) { + Ok(r) => r, + Err(e) => return UploadResult::Err(format!("解析响应失败: {}", e)), + }; + + match result.share_url { + Some(url) => UploadResult::Ok(url), + None => UploadResult::Err(result.error.unwrap_or_else(|| "未知服务器响应".to_owned())), + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_result(); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(16.0); + ui.heading("临时文件传输"); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("API 地址:"); + ui.add_sized( + [400.0, 24.0], + egui::TextEdit::singleline(&mut self.api_url) + .hint_text("https://xiaji-temp.duckdns.org/api/upload"), + ); + }); + + ui.add_space(8.0); + + let drop_frame = egui::Frame::dark_canvas(ui.style()) + .inner_margin(egui::Margin::same(40)) + .corner_radius(egui::CornerRadius::same(12)); + + drop_frame.show(ui, |ui| { + ui.set_min_size(egui::vec2(440.0, 200.0)); + ui.vertical_centered(|ui| { + ui.add_space(60.0); + if self.uploading { + ui.add(egui::Spinner::new()); + ui.add_space(8.0); + ui.label("正在上传..."); + } else { + ui.label( + egui::RichText::new("拖放文件到此处上传") + .size(24.0) + .color(egui::Color32::from_gray(180)), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("最大 500MB · 过期 24小时") + .size(14.0) + .color(egui::Color32::from_gray(140)), + ); + } + }); + }); + + let dropped = ctx.input(|i| { + if !i.raw.dropped_files.is_empty() { + Some(i.raw.dropped_files.clone()) + } else { + None + } + }); + + if let Some(files) = dropped { + if !self.uploading { + for f in files { + if let Some(ref path) = f.path { + self.start_upload(path.to_owned(), self.api_url.clone()); + break; + } + } + } + } + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("状态:"); + ui.label( + egui::RichText::new(&self.status) + .color(egui::Color32::from_rgb(100, 180, 100)), + ); + }); + + ui.add_space(8.0); + + if !self.share_url.is_empty() { + ui.horizontal(|ui| { + ui.label("下载链接:"); + ui.add_sized( + [400.0, 24.0], + egui::TextEdit::singleline(&mut self.share_url) + .interactive(false), + ); + if ui.button("复制").clicked() { + ui.ctx().copy_text(self.share_url.clone()); + } + }); + } + }); + }); + + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +fn load_chinese_font(ctx: &egui::Context) { + let font_paths = [ + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\simhei.ttf", + "C:\\Windows\\Fonts\\simsun.ttc", + ]; + + for path in &font_paths { + if let Ok(data) = std::fs::read(path) { + let mut fonts = egui::FontDefinitions::default(); + fonts + .font_data + .insert("chinese".to_owned(), std::sync::Arc::new(egui::FontData::from_owned(data))); + fonts + .families + .get_mut(&egui::FontFamily::Proportional) + .unwrap() + .insert(0, "chinese".to_owned()); + fonts + .families + .get_mut(&egui::FontFamily::Monospace) + .unwrap() + .push("chinese".to_owned()); + ctx.set_fonts(fonts); + return; + } + } +} + +fn main() { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([560.0, 480.0]) + .with_resizable(false) + .with_title("临时文件传输"), + ..Default::default() + }; + + let _ = eframe::run_native( + "临时文件传输", + options, + Box::new(|cc| { + load_chinese_font(&cc.egui_ctx); + Ok(Box::new(App::new())) + }), + ); +}