feat: 新增 Frp 下载管理功能,支持版本拉取、下载、导入和删除

新增 Frp 下载相关的后端命令、前端组件和类型定义,包括:
- 后端:添加 frp 模块,实现版本拉取、下载、导入、删除逻辑,新增数据库表
- 前端:新增 FrpCard 组件、下载页面,集成版本选择和下载进度展示
- 国际化:新增中英双语的 Frp 相关文案
- 依赖:添加 reqwest、futures-util、tokio 等网络和异步处理依赖
This commit is contained in:
Yuhang Wu
2026-10-13 16:39:29 +08:00
parent 50bf5f7e36
commit db7b1096d1
16 changed files with 1608 additions and 19 deletions
+154 -3
View File
@@ -603,6 +603,12 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1036,6 +1042,20 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1251,6 +1271,8 @@ name = "frpilot"
version = "0.1.0"
dependencies = [
"chrono",
"futures-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"sqlx",
@@ -1264,6 +1286,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater",
"tokio",
]
[[package]]
@@ -1654,6 +1677,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "h2"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.2",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1794,7 +1836,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1820,6 +1864,22 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@@ -2452,6 +2512,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -3299,6 +3365,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.5"
@@ -3334,7 +3443,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -3483,6 +3592,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3705,6 +3820,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
@@ -4388,7 +4515,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.5",
"serde",
"serde_json",
"serde_repr",
@@ -4623,7 +4750,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.5",
"rustls",
"semver",
"serde",
@@ -4901,6 +5028,16 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
@@ -4931,6 +5068,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -5393,6 +5531,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
+3
View File
@@ -40,6 +40,9 @@ tauri-plugin-dialog = "2.7.2"
tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-prevent-default = "6.0.1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
tokio = { version = "1", features = ["fs", "io-util"] }
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
+699
View File
@@ -0,0 +1,699 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use futures_util::StreamExt;
use serde_json::Value;
use sqlx::SqlitePool;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri_helper::auto_collect_command;
use tokio::io::AsyncWriteExt;
use crate::models::{
ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease,
};
/// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像)
const GITHUB_MIRROR: &str = "https://gh-proxy.com/";
/// frp 仓库 releases API
const RELEASES_API: &str = "https://api.github.com/repos/fatedier/frp/releases?per_page=30";
/// 下载进度事件名
const PROGRESS_EVENT: &str = "frp-download-progress";
/// frp_downloads 表统一查询字段
macro_rules! frp_fields {
() => {
"id, version, os, arch, platform, file_name, archive_path, frpc_path, frps_path, file_size, source, created_at, updated_at"
};
}
fn http_client() -> Result<reqwest::Client, reqwest::Error> {
reqwest::Client::builder()
.user_agent("Frpilot/0.1 (+https://github.com/fatedier/frp)")
.connect_timeout(Duration::from_secs(15))
.build()
}
/// 当前运行平台(os + arch),并映射为 frp 发行包的命名
fn current_platform() -> (String, String) {
let os = match std::env::consts::OS {
"macos" => "darwin",
other => other,
}
.to_string();
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
other => other,
}
.to_string();
(os, arch)
}
/// 解析发行包文件名,返回 (版本, os, arch)
///
/// 形如 `frp_0.71.0_windows_amd64.zip` / `frp_0.71.0_linux_arm_hf.tar.gz`,
/// arch 部分可能包含下划线(如 arm_hf、mips_softfloat),故剩余部分整体拼接。
fn parse_asset(name: &str) -> Option<(String, String, String)> {
let rest = name.strip_prefix("frp_")?;
let rest = rest
.strip_suffix(".tar.gz")
.or_else(|| rest.strip_suffix(".zip"))?;
let mut parts = rest.split('_');
let version = parts.next()?.to_string();
let os = parts.next()?.to_string();
let arch = parts.collect::<Vec<_>>().join("_");
if version.is_empty() || os.is_empty() || arch.is_empty() {
return None;
}
Some((version, os, arch))
}
/// 从文件名中解析版本号(形如 0.71.0),返回带 v 前缀的版本号
fn parse_version(name: &str) -> Option<String> {
for raw in name.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == 'v' || c == 'V')) {
let token = raw.trim_start_matches(|c| c == 'v' || c == 'V');
let parts: Vec<&str> = token.split('.').collect();
let valid = parts.len() >= 3
&& parts[..3]
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
if valid {
return Some(format!("v{}.{}.{}", parts[0], parts[1], parts[2]));
}
}
None
}
async fn fetch_locals(pool: &SqlitePool) -> Result<Vec<FrpDownload>, sqlx::Error> {
sqlx::query_as::<_, FrpDownload>(concat!(
"SELECT ",
frp_fields!(),
" FROM frp_downloads ORDER BY id ASC"
))
.fetch_all(pool)
.await
}
async fn fetch_local(
pool: &SqlitePool,
version: &str,
platform: &str,
) -> Result<FrpDownload, sqlx::Error> {
sqlx::query_as::<_, FrpDownload>(concat!(
"SELECT ",
frp_fields!(),
" FROM frp_downloads WHERE version = ? AND platform = ?"
))
.bind(version)
.bind(platform)
.fetch_one(pool)
.await
}
/// 拉取 frp releases(镜像优先,失败回退直连)
async fn fetch_releases(client: &reqwest::Client) -> Result<Vec<Value>, String> {
let urls = [
format!("{GITHUB_MIRROR}{RELEASES_API}"),
RELEASES_API.to_string(),
];
let mut last_err = String::new();
for url in urls {
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.json::<Value>().await {
Ok(Value::Array(list)) => return Ok(list),
Ok(_) => last_err = "返回数据格式异常".to_string(),
Err(e) => last_err = e.to_string(),
},
Ok(resp) => last_err = format!("HTTP {}", resp.status()),
Err(e) => last_err = e.to_string(),
}
}
Err(format!("获取 Frp 版本列表失败:{last_err}"))
}
/// 应用数据目录下的 frp 版本存储根目录
fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("versions"))
}
/// 发送下载进度事件
fn emit_progress(
app: &AppHandle,
payload: &FrpDownloadPayload,
downloaded: u64,
total: u64,
status: &str,
message: Option<&str>,
) {
let percent = if total > 0 {
((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0).round() as u8
} else {
0
};
let _ = app.emit(
PROGRESS_EVENT,
FrpDownloadProgress {
key: payload.file_name.clone(),
version: payload.version.clone(),
platform: payload.platform.clone(),
downloaded,
total,
percent,
status: status.to_string(),
message: message.map(|m| m.to_string()),
},
);
}
/// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录)
///
/// 前端根据「展示所有架构的 Frpc / Frps」开关在本地过滤,避免频繁请求
/// GitHub API 触发限流;默认仅展示当前运行平台由前端完成。
#[tauri::command]
#[auto_collect_command]
pub async fn get_frp_releases(
state: State<'_, SqlitePool>,
) -> Result<ApiResponse<Vec<FrpRelease>>, String> {
let client = match http_client() {
Ok(c) => c,
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
let releases = match fetch_releases(&client).await {
Ok(list) => list,
Err(e) => return Ok(ApiResponse::error(500, &e)),
};
let locals = match fetch_locals(&state).await {
Ok(list) => list,
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
let (cur_os, cur_arch) = current_platform();
let cur_platform = format!("{cur_os}_{cur_arch}");
let mut list: Vec<FrpRelease> = Vec::new();
let mut matched: HashSet<String> = HashSet::new();
for rel in &releases {
let version = rel
.get("tag_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if version.is_empty() {
continue;
}
let published_at = rel
.get("published_at")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let Some(assets) = rel.get("assets").and_then(Value::as_array) else {
continue;
};
for asset in assets {
let name = asset.get("name").and_then(Value::as_str).unwrap_or_default();
let Some((_v, os, arch)) = parse_asset(name) else {
continue;
};
let platform = format!("{os}_{arch}");
let matches = os == cur_os && arch == cur_arch;
let size = asset.get("size").and_then(Value::as_i64).unwrap_or(0);
let download_count = asset
.get("download_count")
.and_then(Value::as_i64)
.unwrap_or(0);
let url = asset
.get("browser_download_url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let local = locals
.iter()
.find(|l| l.version == version && l.platform == platform);
if let Some(l) = local {
matched.insert(format!("{}|{}", l.version, l.platform));
}
list.push(FrpRelease {
version: version.clone(),
os,
arch,
platform,
file_name: name.to_string(),
size,
download_count,
published_at: published_at.clone(),
mirror_url: format!("{GITHUB_MIRROR}{url}"),
download_url: url,
downloaded: local.is_some(),
is_current: matches,
local_only: false,
local_id: local.map(|l| l.id),
frpc_path: local.and_then(|l| l.frpc_path.clone()),
frps_path: local.and_then(|l| l.frps_path.clone()),
});
}
}
// 本机已下载 / 导入但未匹配到发行包的记录,单独作为本地卡片展示
for l in &locals {
if matched.contains(&format!("{}|{}", l.version, l.platform)) {
continue;
}
list.push(FrpRelease {
version: l.version.clone(),
os: l.os.clone(),
arch: l.arch.clone(),
platform: l.platform.clone(),
file_name: l.file_name.clone(),
size: l.file_size,
download_count: 0,
published_at: String::new(),
download_url: String::new(),
mirror_url: String::new(),
downloaded: true,
is_current: l.platform == cur_platform,
local_only: true,
local_id: Some(l.id),
frpc_path: l.frpc_path.clone(),
frps_path: l.frps_path.clone(),
});
}
// 当前平台优先,其次版本新到旧
list.sort_by(|a, b| {
let ap = a.platform == cur_platform;
let bp = b.platform == cur_platform;
bp.cmp(&ap)
.then_with(|| b.published_at.cmp(&a.published_at))
.then_with(|| b.version.cmp(&a.version))
});
Ok(ApiResponse::success(list))
}
/// 获取本机已下载 / 已导入的 Frp 版本列表(仪表盘选择 frpc 版本用)
#[tauri::command]
#[auto_collect_command]
pub async fn get_frp_downloads(
state: State<'_, SqlitePool>,
) -> Result<ApiResponse<Vec<FrpDownload>>, String> {
let list = sqlx::query_as::<_, FrpDownload>(concat!(
"SELECT ",
frp_fields!(),
" FROM frp_downloads ORDER BY created_at DESC, id DESC"
))
.fetch_all(&*state)
.await;
Ok(match list {
Ok(list) => ApiResponse::success(list),
Err(e) => ApiResponse::error(500, &e.to_string()),
})
}
/// 下载并解压指定 Frp 发行包
#[tauri::command]
#[auto_collect_command]
pub async fn download_frp(
app: AppHandle,
state: State<'_, SqlitePool>,
payload: FrpDownloadPayload,
) -> Result<ApiResponse<FrpDownload>, String> {
match do_download(&app, &state, &payload).await {
Ok(row) => Ok(ApiResponse::success(row)),
Err(e) => {
emit_progress(&app, &payload, 0, 0, "error", Some(&e));
Ok(ApiResponse::error(500, &e))
}
}
}
async fn do_download(
app: &AppHandle,
pool: &SqlitePool,
payload: &FrpDownloadPayload,
) -> Result<FrpDownload, String> {
let client = http_client().map_err(|e| e.to_string())?;
let ver_dir = versions_dir(app)?.join(&payload.version);
let extract_dir = ver_dir.join(&payload.platform);
tokio::fs::create_dir_all(&extract_dir)
.await
.map_err(|e| e.to_string())?;
let archive_path = ver_dir.join(&payload.file_name);
// 压缩包不存在(或为空)时才重新下载
let need_download = match tokio::fs::metadata(&archive_path).await {
Ok(meta) => meta.len() == 0,
Err(_) => true,
};
if need_download {
download_file(app, &client, payload, &archive_path).await?;
}
// 已解压过则跳过解压
if find_binary(&extract_dir, "frpc").is_none() && find_binary(&extract_dir, "frps").is_none() {
emit_progress(app, payload, 0, 0, "extracting", None);
extract_archive(&archive_path, &extract_dir).await?;
}
let frpc = find_binary(&extract_dir, "frpc");
let frps = find_binary(&extract_dir, "frps");
if frpc.is_none() && frps.is_none() {
return Err("解压完成,但未找到 frpc / frps 可执行文件".to_string());
}
if let Some(path) = &frpc {
make_executable(path);
}
if let Some(path) = &frps {
make_executable(path);
}
let size = tokio::fs::metadata(&archive_path)
.await
.map(|m| m.len() as i64)
.unwrap_or(0);
sqlx::query(
"INSERT INTO frp_downloads (version, os, arch, platform, file_name, archive_path, frpc_path, frps_path, file_size, source) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'download') \
ON CONFLICT(version, platform) DO UPDATE SET \
file_name = excluded.file_name, archive_path = excluded.archive_path, \
frpc_path = excluded.frpc_path, frps_path = excluded.frps_path, \
file_size = excluded.file_size, source = 'download', \
updated_at = datetime('now', 'localtime')",
)
.bind(&payload.version)
.bind(&payload.os)
.bind(&payload.arch)
.bind(&payload.platform)
.bind(&payload.file_name)
.bind(archive_path.to_string_lossy().to_string())
.bind(frpc.as_ref().map(|p| p.to_string_lossy().to_string()))
.bind(frps.as_ref().map(|p| p.to_string_lossy().to_string()))
.bind(size)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
emit_progress(app, payload, 1, 1, "done", None);
fetch_local(pool, &payload.version, &payload.platform)
.await
.map_err(|e| e.to_string())
}
/// 下载压缩包(镜像优先,失败回退直连),并持续上报进度
async fn download_file(
app: &AppHandle,
client: &reqwest::Client,
payload: &FrpDownloadPayload,
archive_path: &Path,
) -> Result<(), String> {
let urls = [
format!("{GITHUB_MIRROR}{}", payload.download_url),
payload.download_url.clone(),
];
let mut last_err = String::new();
for url in urls {
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let total = resp.content_length().unwrap_or(0);
let mut file = tokio::fs::File::create(archive_path)
.await
.map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
let mut stream = resp.bytes_stream();
let mut stream_err: Option<String> = None;
emit_progress(app, payload, 0, total, "downloading", None);
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
file.write_all(&bytes).await.map_err(|e| e.to_string())?;
downloaded += bytes.len() as u64;
emit_progress(app, payload, downloaded, total, "downloading", None);
}
Err(e) => {
stream_err = Some(e.to_string());
break;
}
}
}
if let Some(err) = stream_err {
// 本次下载中断,清理残缺文件后尝试下一个源
let _ = file.shutdown().await;
let _ = tokio::fs::remove_file(archive_path).await;
last_err = err;
continue;
}
let _ = file.flush().await;
return Ok(());
}
Ok(resp) => last_err = format!("HTTP {}", resp.status()),
Err(e) => last_err = e.to_string(),
}
}
Err(format!("下载失败:{last_err}"))
}
/// 解压发行包。Windows 为 zip、类 Unix 为 tar.gz,统一使用系统 tar 命令
/// (Windows 10+ 内置 bsdtar,可同时处理 zip 与 tar.gz)
async fn extract_archive(archive: &Path, dest: &Path) -> Result<(), String> {
let archive = archive.to_path_buf();
let dest = dest.to_path_buf();
tokio::task::spawn_blocking(move || {
let output = std::process::Command::new("tar")
.arg("-xf")
.arg(&archive)
.arg("-C")
.arg(&dest)
.output()
.map_err(|e| format!("调用 tar 解压失败:{e}"))?;
if !output.status.success() {
return Err(format!(
"解压失败:{}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
})
.await
.map_err(|e| e.to_string())?
}
/// 在目录中递归查找 frpc / frps 可执行文件
fn find_binary(dir: &Path, stem: &str) -> Option<PathBuf> {
let stem_lower = stem.to_ascii_lowercase();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&current) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_ascii_lowercase())
else {
continue;
};
if name == stem_lower || name == format!("{stem_lower}.exe") {
return Some(path);
}
}
}
None
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
/// 删除已下载 / 已导入的版本(同时清理本地文件)
#[tauri::command]
#[auto_collect_command]
pub async fn delete_frp(
app: AppHandle,
state: State<'_, SqlitePool>,
id: i64,
) -> Result<ApiResponse<()>, String> {
let row = match sqlx::query_as::<_, FrpDownload>(concat!(
"SELECT ",
frp_fields!(),
" FROM frp_downloads WHERE id = ?"
))
.bind(id)
.fetch_optional(&*state)
.await
{
Ok(Some(row)) => row,
Ok(None) => return Ok(ApiResponse::error(404, "记录不存在")),
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
if let Ok(base) = versions_dir(&app) {
let ver_dir = base.join(&row.version);
let _ = tokio::fs::remove_dir_all(ver_dir.join(&row.platform)).await;
if let Some(archive) = &row.archive_path {
let _ = tokio::fs::remove_file(archive).await;
}
// 版本目录已空则一并删除
if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await {
if matches!(entries.next_entry().await, Ok(None)) {
let _ = tokio::fs::remove_dir(&ver_dir).await;
}
}
}
match sqlx::query("DELETE FROM frp_downloads WHERE id = ?")
.bind(id)
.execute(&*state)
.await
{
Ok(_) => Ok(ApiResponse::success_empty()),
Err(e) => Ok(ApiResponse::error(500, &e.to_string())),
}
}
/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件)
#[tauri::command]
#[auto_collect_command]
pub async fn import_frp(
app: AppHandle,
state: State<'_, SqlitePool>,
) -> Result<ApiResponse<Vec<FrpDownload>>, String> {
use tauri_plugin_dialog::DialogExt;
let picked = app
.dialog()
.file()
.set_title("选择本地已下载的 frpc / frps")
.blocking_pick_files();
let Some(files) = picked else {
return Ok(ApiResponse::success(Vec::new()));
};
let (os, arch) = current_platform();
let platform = format!("{os}_{arch}");
let mut result = Vec::new();
for file in files {
let path = match file.into_path() {
Ok(path) => path,
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
let file_name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
let lower = file_name.to_ascii_lowercase();
let is_frps = lower.contains("frps");
let version = parse_version(&file_name).unwrap_or_else(|| file_name.clone());
let full = path.to_string_lossy().to_string();
let (frpc_path, frps_path) = if is_frps {
(None, Some(full))
} else {
(Some(full), None)
};
let exec = sqlx::query(
"INSERT INTO frp_downloads (version, os, arch, platform, file_name, frpc_path, frps_path, file_size, source) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'import') \
ON CONFLICT(version, platform) DO UPDATE SET \
frpc_path = COALESCE(excluded.frpc_path, frp_downloads.frpc_path), \
frps_path = COALESCE(excluded.frps_path, frp_downloads.frps_path), \
file_size = CASE WHEN excluded.file_size > 0 THEN excluded.file_size ELSE frp_downloads.file_size END, \
source = 'import', updated_at = datetime('now', 'localtime')",
)
.bind(&version)
.bind(&os)
.bind(&arch)
.bind(&platform)
.bind(&file_name)
.bind(frpc_path)
.bind(frps_path)
.bind(size)
.execute(&*state)
.await;
match exec {
Ok(_) => {
if let Ok(row) = fetch_local(&state, &version, &platform).await {
result.push(row);
}
}
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
}
}
Ok(ApiResponse::success(result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_asset_handles_common_names() {
assert_eq!(
parse_asset("frp_0.71.0_windows_amd64.zip"),
Some(("0.71.0".into(), "windows".into(), "amd64".into()))
);
assert_eq!(
parse_asset("frp_0.71.0_linux_arm64.tar.gz"),
Some(("0.71.0".into(), "linux".into(), "arm64".into()))
);
// arch 含下划线的情况
assert_eq!(
parse_asset("frp_0.71.0_linux_arm_hf.tar.gz"),
Some(("0.71.0".into(), "linux".into(), "arm_hf".into()))
);
assert_eq!(parse_asset("frp_0.71.0_linux_mips_softfloat.tar.gz"), {
Some(("0.71.0".into(), "linux".into(), "mips_softfloat".into()))
});
assert_eq!(parse_asset("readme.md"), None);
}
#[test]
fn parse_version_extracts_semver() {
assert_eq!(parse_version("frpc_0.71.0_windows_amd64.exe"), Some("v0.71.0".into()));
assert_eq!(parse_version("frps-v0.60.0"), Some("v0.60.0".into()));
assert_eq!(parse_version("frpc.exe"), None);
assert_eq!(parse_version("frpc_0.71.exe"), None);
}
#[test]
fn current_platform_is_known() {
let (os, arch) = current_platform();
assert!(!os.is_empty());
assert!(!arch.is_empty());
println!("current platform: {os}_{arch}");
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
pub mod tunnel;
pub mod server;
pub mod system;
pub mod system;
pub mod frp;
+3
View File
@@ -4,6 +4,9 @@ mod services;
use tauri::Manager;
use crate::commands::frp::{
delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp,
};
use crate::commands::server::{
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
update_server,
+99
View File
@@ -186,6 +186,105 @@ pub struct TunnelPayload { /// 编辑时携带,新增时为空
pub enabled: bool,
}
/// 已下载 / 已导入的 Frp 版本(对应 SQLite 中的 frp_downloads 表)
///
/// 一个发行包(version + platform)同时包含 frpc 与 frps 两个可执行文件,
/// 故 `frpc_path` / `frps_path` 分别记录解压(或导入)后的可执行文件路径。
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct FrpDownload {
pub id: i64,
/// 版本号,如 v0.71.0
pub version: String,
/// 操作系统,如 windows / darwin / linux
pub os: String,
/// CPU 架构,如 amd64 / arm64
pub arch: String,
/// 平台标识(os_arch),如 windows_amd64
pub platform: String,
/// 发行包文件名
pub file_name: String,
/// 压缩包保存路径(导入的记录可能为空)
pub archive_path: Option<String>,
/// 解压 / 导入后的 frpc 可执行文件路径
pub frpc_path: Option<String>,
/// 解压 / 导入后的 frps 可执行文件路径
pub frps_path: Option<String>,
/// 文件大小(字节)
pub file_size: i64,
/// 来源:download(应用内下载)/ import(本地导入)
pub source: String,
pub created_at: String,
pub updated_at: String,
}
/// GitHub 上的 Frp 发行包(前端卡片展示用)
#[derive(Debug, Serialize, Deserialize)]
pub struct FrpRelease {
/// 版本号,如 v0.71.0
pub version: String,
/// 操作系统
pub os: String,
/// CPU 架构
pub arch: String,
/// 平台标识(os_arch)
pub platform: String,
/// 发行包文件名
pub file_name: String,
/// 文件大小(字节)
pub size: i64,
/// GitHub 下载次数
pub download_count: i64,
/// 发布日期(ISO 8601)
pub published_at: String,
/// GitHub 原始下载地址
pub download_url: String,
/// 镜像加速后的下载地址
pub mirror_url: String,
/// 是否已下载 / 已导入
pub downloaded: bool,
/// 是否匹配当前运行平台(用于前端默认过滤)
pub is_current: bool,
/// 是否为仅本机存在的记录(未匹配到发行包)
pub local_only: bool,
/// 本机记录 ID(用于删除)
pub local_id: Option<i64>,
/// 本机 frpc 可执行文件路径
pub frpc_path: Option<String>,
/// 本机 frps 可执行文件路径
pub frps_path: Option<String>,
}
/// 下载 / 导入请求
#[derive(Debug, Serialize, Deserialize)]
pub struct FrpDownloadPayload {
pub version: String,
pub os: String,
pub arch: String,
pub platform: String,
pub file_name: String,
/// GitHub 原始下载地址(后端会拼接镜像前缀)
pub download_url: String,
}
/// Frp 下载进度事件(通过 `frp-download-progress` 事件推送)
#[derive(Debug, Clone, Serialize)]
pub struct FrpDownloadProgress {
/// 事件键,与发行包文件名一致,用于前端定位卡片
pub key: String,
pub version: String,
pub platform: String,
/// 已下载字节数
pub downloaded: u64,
/// 总字节数(0 表示未知)
pub total: u64,
/// 进度百分比(0 - 100)
pub percent: u8,
/// 状态:downloading / extracting / done / error
pub status: String,
/// 附加信息(错误原因等)
pub message: Option<String>,
}
/// 系统资源占用(百分比,0 - 100)
#[derive(Debug, Serialize, Deserialize)]
pub struct SystemInfo {
+25
View File
@@ -76,5 +76,30 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
.execute(&pool)
.await?;
// 已下载 / 已导入的 Frp 版本表
// 同一版本 + 平台唯一,一个发行包内同时含 frpc 与 frps 两个可执行文件
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS frp_downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
os TEXT NOT NULL,
arch TEXT NOT NULL,
platform TEXT NOT NULL,
file_name TEXT NOT NULL,
archive_path TEXT,
frpc_path TEXT,
frps_path TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'download',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
UNIQUE(version, platform)
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
}