diff --git a/docs/Frpilot开发提示词.md b/docs/Frpilot开发提示词.md index a226a4b..795bcae 100644 --- a/docs/Frpilot开发提示词.md +++ b/docs/Frpilot开发提示词.md @@ -83,4 +83,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下: - 三个卡片当中的服务器数量和隧道数量合并到一个卡片里面,这一个卡片里面上方展示服务器数量,下方展示隧道数量 - 原本的隧道数量卡片改为展示当前运行系统主机名、系统架构、IP地址等,通过Rust端sysinfo获取 -- 其余卡片保持不动 \ No newline at end of file +- 其余卡片保持不动 + +#### 3.4 Frpc下载功能开发 + +- frp的GitHub仓库地址是https://github.com/fatedier/frp +- 初始化一张表,用来保存已下载的Frpc版本和保存路径等信息,已下载的Frpc在获取到的列表里面展示为已下载等标记 +- 考虑到国内网络环境访问GitHub不稳定,所以拉取Frpc版本和下载的时候使用GitHub 镜像加速,拉取Frpc版本和下载的时候,需要根据软件运行系统架构展示对应的Frpc版本(默认情况); +- 页面上方卡片栏里面增加一个开关选项,用于开启是否展示所有架构的Frpc;再增加一个开关选项,用于开启是否展示所有架构的Frps,可以将该软件作为frp下载器使用;再有一个刷新按钮,用于刷新信息;再加一个按钮用于导入本地已下载的Frpc +- 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条 +- 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本 + +开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 796c3c8..15ff43d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f77ebca..a91a4a6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] diff --git a/src-tauri/src/commands/frp.rs b/src-tauri/src/commands/frp.rs new file mode 100644 index 0000000..290866e --- /dev/null +++ b/src-tauri/src/commands/frp.rs @@ -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::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::>().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 { + 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, 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 { + 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, 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::().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 { + 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>, 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 = Vec::new(); + let mut matched: HashSet = 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>, 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, 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 { + 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 = 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 { + 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(¤t) 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, 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>, 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}"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c4898f6..d4de695 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod tunnel; pub mod server; -pub mod system; \ No newline at end of file +pub mod system; +pub mod frp; \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e63b149..ce2c054 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 89a0255..f1cea17 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -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, + /// 解压 / 导入后的 frpc 可执行文件路径 + pub frpc_path: Option, + /// 解压 / 导入后的 frps 可执行文件路径 + pub frps_path: Option, + /// 文件大小(字节) + 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, + /// 本机 frpc 可执行文件路径 + pub frpc_path: Option, + /// 本机 frps 可执行文件路径 + pub frps_path: Option, +} + +/// 下载 / 导入请求 +#[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, +} + /// 系统资源占用(百分比,0 - 100) #[derive(Debug, Serialize, Deserialize)] pub struct SystemInfo { diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 081690c..4c02249 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -76,5 +76,30 @@ pub async fn init_pool(app: &AppHandle) -> Result { .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) } diff --git a/src/api/frp.ts b/src/api/frp.ts new file mode 100644 index 0000000..2c2bd84 --- /dev/null +++ b/src/api/frp.ts @@ -0,0 +1,44 @@ +import { invoke } from "@tauri-apps/api/core" +import type { ApiResponse } from "@/types/api" +import type { FrpDownload, FrpDownloadPayload, FrpRelease } from "@/types/frp" + +/** + * 调用后端命令并统一解包 ApiResponse。 + * + * 后端所有 Frp 下载相关命令均返回 `ApiResponse`,业务失败时 + * `success === false`,此处统一抛出异常,交由调用方提示。 + */ +async function call(command: string, args?: Record): Promise { + const res = await invoke>(command, args) + if (!res?.success) { + throw new Error(res?.msg || "操作失败") + } + return res.data as T +} + +/** + * 获取 Frp 版本列表(返回所有架构,前端按开关过滤) + */ +export function getFrpReleases() { + return call("get_frp_releases") +} + +/** 下载并解压指定 Frp 发行包 */ +export function downloadFrp(payload: FrpDownloadPayload) { + return call("download_frp", { payload }) +} + +/** 获取本机已下载 / 已导入的 Frp 版本列表(仪表盘选择 frpc 版本用) */ +export function getFrpDownloads() { + return call("get_frp_downloads") +} + +/** 删除已下载 / 已导入的版本 */ +export function deleteFrp(id: number) { + return call("delete_frp", { id }) +} + +/** 导入本地已下载的 frpc / frps(返回导入后的记录) */ +export function importFrp() { + return call("import_frp") +} diff --git a/src/components/frp/FrpCard.vue b/src/components/frp/FrpCard.vue new file mode 100644 index 0000000..852abe7 --- /dev/null +++ b/src/components/frp/FrpCard.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index f8b73c1..f65fa1c 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -41,6 +41,7 @@ "arch": "Architecture", "ip": "IP address", "frpcVersion": "Frpc version", + "frpcVersionEmpty": "No downloaded version yet. Download one from the Frpc download page first.", "start": "One-click start", "stop": "Stop", "logs": "Live logs", @@ -180,6 +181,34 @@ "portRange": "Port must be between 1 and 65535" } }, + "frp": { + "refresh": "Refresh", + "import": "Import local", + "showAllArchFrpc": "All-arch Frpc", + "showAllArchFrps": "All-arch Frps", + "empty": "No Frp release data. Click Refresh to retry.", + "downloaded": "Downloaded", + "local": "Local", + "download": "Download", + "copyLink": "Copy download link", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete {version} ({platform})?", + "size": "Size", + "downloadCount": "Downloads", + "publishedAt": "Published", + "downloadSuccess": "Download completed", + "deleteSuccess": "Deleted successfully", + "copySuccess": "Download link copied", + "copyFailed": "Copy failed, please copy manually", + "importSuccess": "Imported {count} file(s)", + "importEmpty": "No file selected", + "status": { + "downloading": "Downloading", + "extracting": "Extracting", + "done": "Done", + "error": "Download failed" + } + }, "common": { "developing": "This feature is under construction", "confirm": "OK", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 50b8f07..94dc77d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -41,6 +41,7 @@ "arch": "系统架构", "ip": "IP 地址", "frpcVersion": "Frpc 版本", + "frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载", "start": "一键启动", "stop": "停止", "logs": "实时日志", @@ -180,6 +181,34 @@ "portRange": "端口号必须在 1 - 65535 之间" } }, + "frp": { + "refresh": "刷新", + "import": "导入本地", + "showAllArchFrpc": "所有架构 Frpc", + "showAllArchFrps": "所有架构 Frps", + "empty": "暂无 Frp 版本数据,点击刷新重试", + "downloaded": "已下载", + "local": "本地", + "download": "下载", + "copyLink": "复制下载链接", + "delete": "删除", + "deleteConfirm": "确定要删除已下载的 {version}({platform})吗?", + "size": "大小", + "downloadCount": "下载次数", + "publishedAt": "发布日期", + "downloadSuccess": "下载完成", + "deleteSuccess": "删除成功", + "copySuccess": "下载链接已复制", + "copyFailed": "复制失败,请手动复制", + "importSuccess": "已导入 {count} 个文件", + "importEmpty": "未选择任何文件", + "status": { + "downloading": "下载中", + "extracting": "解压中", + "done": "已完成", + "error": "下载失败" + } + }, "common": { "developing": "功能开发中,敬请期待", "confirm": "确定", diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 2cc27cc..342eeab 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -30,7 +30,6 @@ declare module 'vue' { AModal: typeof import('ant-design-vue/es')['Modal'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] AProgress: typeof import('ant-design-vue/es')['Progress'] - AResult: typeof import('ant-design-vue/es')['Result'] ARow: typeof import('ant-design-vue/es')['Row'] ASegmented: typeof import('ant-design-vue/es')['Segmented'] ASelect: typeof import('ant-design-vue/es')['Select'] @@ -41,6 +40,7 @@ declare module 'vue' { ATag: typeof import('ant-design-vue/es')['Tag'] ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATooltip: typeof import('ant-design-vue/es')['Tooltip'] + FrpCard: typeof import('./../components/frp/FrpCard.vue')['default'] MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/types/frp.ts b/src/types/frp.ts new file mode 100644 index 0000000..4d0ba21 --- /dev/null +++ b/src/types/frp.ts @@ -0,0 +1,89 @@ +/** + * Frp 下载相关的类型定义,字段与 Rust 端 `models::FrpRelease` / `models::FrpDownload` + * 等一一对应。 + */ + +/** GitHub 上的 Frp 发行包(卡片展示用) */ +export interface FrpRelease { + /** 版本号,如 v0.71.0 */ + version: string + /** 操作系统,如 windows / darwin / linux */ + os: string + /** CPU 架构,如 amd64 / arm64 */ + arch: string + /** 平台标识(os_arch),如 windows_amd64 */ + platform: string + /** 发行包文件名 */ + file_name: string + /** 文件大小(字节) */ + size: number + /** GitHub 下载次数 */ + download_count: number + /** 发布日期(ISO 8601) */ + published_at: string + /** GitHub 原始下载地址 */ + download_url: string + /** 镜像加速后的下载地址 */ + mirror_url: string + /** 是否已下载 / 已导入 */ + downloaded: boolean + /** 是否匹配当前运行平台(用于前端默认过滤) */ + is_current: boolean + /** 是否为仅本机存在的记录(未匹配到发行包) */ + local_only: boolean + /** 本机记录 ID(用于删除) */ + local_id: number | null + /** 本机 frpc 可执行文件路径 */ + frpc_path: string | null + /** 本机 frps 可执行文件路径 */ + frps_path: string | null +} + +/** 已下载 / 已导入的 Frp 版本 */ +export interface FrpDownload { + id: number + version: string + os: string + arch: string + platform: string + file_name: string + archive_path: string | null + frpc_path: string | null + frps_path: string | null + file_size: number + /** 来源:download(应用内下载)/ import(本地导入) */ + source: string + created_at: string + updated_at: string +} + +/** 下载请求 */ +export interface FrpDownloadPayload { + version: string + os: string + arch: string + platform: string + file_name: string + /** GitHub 原始下载地址(后端会拼接镜像前缀) */ + download_url: string +} + +/** 下载进度状态 */ +export type FrpDownloadStatus = "downloading" | "extracting" | "done" | "error" + +/** Frp 下载进度事件 */ +export interface FrpDownloadProgress { + /** 事件键,与发行包文件名一致 */ + key: string + version: string + platform: string + /** 已下载字节数 */ + downloaded: number + /** 总字节数(0 表示未知) */ + total: number + /** 进度百分比(0 - 100) */ + percent: number + status: FrpDownloadStatus + /** 附加信息(错误原因等) */ + message: string | null +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 124c4d9..19ae2a2 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -87,14 +87,22 @@ >
{{ t("dashboard.frpcVersion") }} - +
{{ t("dashboard.start") }} @@ -148,11 +156,13 @@ import { PoweroffOutlined, } from "@ant-design/icons-vue" import { useI18n } from "vue-i18n" +import { getFrpDownloads } from "@/api/frp" import { getServerList } from "@/api/server" import { getSystemDetails } from "@/api/system" import { getTunnelList } from "@/api/tunnel" import { useAppStore } from "@/stores/app" import { useSystemStats } from "@/composables/useSystemStats" +import type { FrpDownload } from "@/types/frp" import type { SystemDetails } from "@/types/system" import { isTauri } from "@/utils/window" @@ -178,13 +188,25 @@ const ipFull = computed(() => (details.value?.ips?.length ? details.value.ips.jo /** 合并卡片中两个统计数值的样式(更紧凑) */ const statValueStyle = { fontSize: "20px", fontWeight: 600, lineHeight: "1.2" } -/** 模拟:已下载的 Frpc 版本列表(下载功能尚未实现) */ -const versionOptions = [ - { label: "v0.61.1 · windows_amd64", value: "v0.61.1" }, - { label: "v0.60.0 · windows_amd64", value: "v0.60.0" }, - { label: "v0.58.2 · windows_amd64", value: "v0.58.2" }, -] -const selectedVersion = ref(versionOptions[0].value) +/** 已下载 / 已导入的 Frp 版本(来自 frp_downloads) */ +const frpDownloads = ref([]) +const versionsLoading = ref(false) +/** 当前选中的版本记录 ID */ +const selectedId = ref(undefined) + +/** 仅展示具备 frpc 可执行文件的记录 */ +const versionOptions = computed(() => + frpDownloads.value + .filter((item) => item.frpc_path) + .map((item) => ({ label: `${item.version} · ${item.platform}`, value: String(item.id) })), +) + +const selectedDownload = computed( + () => frpDownloads.value.find((item) => String(item.id) === selectedId.value) ?? null, +) + +/** 选中的版本号(用于日志展示) */ +const selectedVersion = computed(() => selectedDownload.value?.version ?? "-") const isRunning = computed(() => appStore.runStatus === "running") @@ -265,10 +287,28 @@ async function loadSystemDetails() { } } +/** 拉取已下载 / 已导入的 Frp 版本,供版本下拉选择 */ +async function loadFrpDownloads() { + if (!isTauri()) return + versionsLoading.value = true + try { + frpDownloads.value = (await getFrpDownloads()) ?? [] + // 默认选中最新一个可用版本 + if (!selectedId.value && versionOptions.value.length) { + selectedId.value = versionOptions.value[0].value + } + } catch { + // 读取失败时保持空列表 + } finally { + versionsLoading.value = false + } +} + onMounted(() => { pushLog("INFO", t("dashboard.logReady")) loadCounts() loadSystemDetails() + loadFrpDownloads() }) /** 进度条颜色:低=蓝,中=橙,高=红 */ diff --git a/src/views/Download.vue b/src/views/Download.vue index de0898b..34622c6 100644 --- a/src/views/Download.vue +++ b/src/views/Download.vue @@ -1,19 +1,210 @@