feat(desktop): add Windows GUI upload client with egui (GNU toolchain)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ venv/
|
|||||||
uploads/
|
uploads/
|
||||||
.flaskenv
|
.flaskenv
|
||||||
.env
|
.env
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
*.exe
|
||||||
|
|||||||
5
desktop/.cargo/config.toml
Normal file
5
desktop/.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[build]
|
||||||
|
target = "x86_64-pc-windows-gnu"
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
2
desktop/.gitignore
vendored
Normal file
2
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
16
desktop/Cargo.toml
Normal file
16
desktop/Cargo.toml
Normal file
@@ -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
|
||||||
6
desktop/build.bat
Normal file
6
desktop/build.bat
Normal file
@@ -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
|
||||||
289
desktop/src/main.rs
Normal file
289
desktop/src/main.rs
Normal file
@@ -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<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
filename: Option<String>,
|
||||||
|
share_url: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UploadResult {
|
||||||
|
Ok(String),
|
||||||
|
Err(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
api_url: String,
|
||||||
|
status: String,
|
||||||
|
share_url: String,
|
||||||
|
upload_rx: Option<mpsc::Receiver<UploadResult>>,
|
||||||
|
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()))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user