feat(desktop): add Windows GUI upload client with egui (GNU toolchain)
This commit is contained in:
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