feat(desktop): add Windows GUI upload client with egui (GNU toolchain)

This commit is contained in:
OpenCode Bot
2026-05-24 17:52:36 +08:00
parent 4561aec7e0
commit a50824e831
6 changed files with 321 additions and 0 deletions

View 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
View File

@@ -0,0 +1,2 @@
target/
Cargo.lock

16
desktop/Cargo.toml Normal file
View 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
View 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
View 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()))
}),
);
}