From 15f29c5895c9887af336ccdbd0475f2d0431d1d2 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Thu, 16 Jul 2026 18:26:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=A8=A1=E5=9D=97=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MODULE_DEV_GUIDE.md | 37 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 30 +- src-tauri/src/mihomo_manager.rs | 328 ++++++- src-tauri/src/process_manager.rs | 2 + src-tauri/tauri.conf.json | 3 +- src/App.vue | 2 +- src/components/ui/accordion/Accordion.vue | 18 + .../ui/accordion/AccordionContent.vue | 23 + src/components/ui/accordion/AccordionItem.vue | 24 + .../ui/accordion/AccordionTrigger.vue | 37 + src/components/ui/accordion/index.ts | 4 + .../ui/alert-dialog/AlertDialog.vue | 15 + .../ui/alert-dialog/AlertDialogAction.vue | 18 + .../ui/alert-dialog/AlertDialogCancel.vue | 25 + .../ui/alert-dialog/AlertDialogContent.vue | 44 + .../alert-dialog/AlertDialogDescription.vue | 23 + .../ui/alert-dialog/AlertDialogFooter.vue | 22 + .../ui/alert-dialog/AlertDialogHeader.vue | 17 + .../ui/alert-dialog/AlertDialogTitle.vue | 21 + .../ui/alert-dialog/AlertDialogTrigger.vue | 12 + src/components/ui/alert-dialog/index.ts | 9 + src/components/ui/alert/Alert.vue | 21 + src/components/ui/alert/AlertDescription.vue | 17 + src/components/ui/alert/AlertTitle.vue | 17 + src/components/ui/alert/index.ts | 24 + src/components/ui/badge/Badge.vue | 27 + src/components/ui/badge/index.ts | 23 + src/components/ui/combobox/Combobox.vue | 19 + src/components/ui/combobox/ComboboxAnchor.vue | 23 + src/components/ui/combobox/ComboboxEmpty.vue | 21 + src/components/ui/combobox/ComboboxGroup.vue | 27 + src/components/ui/combobox/ComboboxInput.vue | 42 + src/components/ui/combobox/ComboboxItem.vue | 24 + .../ui/combobox/ComboboxItemIndicator.vue | 23 + src/components/ui/combobox/ComboboxList.vue | 33 + .../ui/combobox/ComboboxSeparator.vue | 21 + .../ui/combobox/ComboboxTrigger.vue | 24 + .../ui/combobox/ComboboxViewport.vue | 23 + src/components/ui/combobox/index.ts | 13 + src/components/ui/dialog/Dialog.vue | 19 + src/components/ui/dialog/DialogClose.vue | 15 + src/components/ui/dialog/DialogContent.vue | 53 + .../ui/dialog/DialogDescription.vue | 23 + src/components/ui/dialog/DialogFooter.vue | 27 + src/components/ui/dialog/DialogHeader.vue | 17 + src/components/ui/dialog/DialogOverlay.vue | 21 + .../ui/dialog/DialogScrollContent.vue | 59 ++ src/components/ui/dialog/DialogTitle.vue | 23 + src/components/ui/dialog/DialogTrigger.vue | 15 + src/components/ui/dialog/index.ts | 10 + src/components/ui/select/Select.vue | 19 + src/components/ui/select/SelectContent.vue | 51 + src/components/ui/select/SelectGroup.vue | 15 + src/components/ui/select/SelectItem.vue | 44 + src/components/ui/select/SelectItemText.vue | 15 + src/components/ui/select/SelectLabel.vue | 17 + .../ui/select/SelectScrollDownButton.vue | 26 + .../ui/select/SelectScrollUpButton.vue | 26 + src/components/ui/select/SelectSeparator.vue | 19 + src/components/ui/select/SelectTrigger.vue | 33 + src/components/ui/select/SelectValue.vue | 15 + src/components/ui/select/index.ts | 11 + src/main.ts | 1 + src/modules/proxy/ProxyModule.vue | 903 ++++++++++++++++-- src/stores/proxyStore.ts | 42 +- src/style.css | 8 + 67 files changed, 2546 insertions(+), 118 deletions(-) create mode 100644 src/components/ui/accordion/Accordion.vue create mode 100644 src/components/ui/accordion/AccordionContent.vue create mode 100644 src/components/ui/accordion/AccordionItem.vue create mode 100644 src/components/ui/accordion/AccordionTrigger.vue create mode 100644 src/components/ui/accordion/index.ts create mode 100644 src/components/ui/alert-dialog/AlertDialog.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogAction.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogCancel.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogContent.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogDescription.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogFooter.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogHeader.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogTitle.vue create mode 100644 src/components/ui/alert-dialog/AlertDialogTrigger.vue create mode 100644 src/components/ui/alert-dialog/index.ts create mode 100644 src/components/ui/alert/Alert.vue create mode 100644 src/components/ui/alert/AlertDescription.vue create mode 100644 src/components/ui/alert/AlertTitle.vue create mode 100644 src/components/ui/alert/index.ts create mode 100644 src/components/ui/badge/Badge.vue create mode 100644 src/components/ui/badge/index.ts create mode 100644 src/components/ui/combobox/Combobox.vue create mode 100644 src/components/ui/combobox/ComboboxAnchor.vue create mode 100644 src/components/ui/combobox/ComboboxEmpty.vue create mode 100644 src/components/ui/combobox/ComboboxGroup.vue create mode 100644 src/components/ui/combobox/ComboboxInput.vue create mode 100644 src/components/ui/combobox/ComboboxItem.vue create mode 100644 src/components/ui/combobox/ComboboxItemIndicator.vue create mode 100644 src/components/ui/combobox/ComboboxList.vue create mode 100644 src/components/ui/combobox/ComboboxSeparator.vue create mode 100644 src/components/ui/combobox/ComboboxTrigger.vue create mode 100644 src/components/ui/combobox/ComboboxViewport.vue create mode 100644 src/components/ui/combobox/index.ts create mode 100644 src/components/ui/dialog/Dialog.vue create mode 100644 src/components/ui/dialog/DialogClose.vue create mode 100644 src/components/ui/dialog/DialogContent.vue create mode 100644 src/components/ui/dialog/DialogDescription.vue create mode 100644 src/components/ui/dialog/DialogFooter.vue create mode 100644 src/components/ui/dialog/DialogHeader.vue create mode 100644 src/components/ui/dialog/DialogOverlay.vue create mode 100644 src/components/ui/dialog/DialogScrollContent.vue create mode 100644 src/components/ui/dialog/DialogTitle.vue create mode 100644 src/components/ui/dialog/DialogTrigger.vue create mode 100644 src/components/ui/dialog/index.ts create mode 100644 src/components/ui/select/Select.vue create mode 100644 src/components/ui/select/SelectContent.vue create mode 100644 src/components/ui/select/SelectGroup.vue create mode 100644 src/components/ui/select/SelectItem.vue create mode 100644 src/components/ui/select/SelectItemText.vue create mode 100644 src/components/ui/select/SelectLabel.vue create mode 100644 src/components/ui/select/SelectScrollDownButton.vue create mode 100644 src/components/ui/select/SelectScrollUpButton.vue create mode 100644 src/components/ui/select/SelectSeparator.vue create mode 100644 src/components/ui/select/SelectTrigger.vue create mode 100644 src/components/ui/select/SelectValue.vue create mode 100644 src/components/ui/select/index.ts diff --git a/MODULE_DEV_GUIDE.md b/MODULE_DEV_GUIDE.md index 471c508..6d2b39d 100644 --- a/MODULE_DEV_GUIDE.md +++ b/MODULE_DEV_GUIDE.md @@ -320,3 +320,40 @@ appStore.toggleModule(moduleId, enabled) 4. **避免循环依赖**:模块的 `index.ts` 只导出配置,不导入其他模块的 store 5. **进程配置的 executable**:通常留空,由模块组件在运行时根据用户设置确定实际路径 6. **内置模块**:设置 `builtin: true` 的模块不可被用户禁用,开关处于禁用状态 + +## 跨模块开发经验(代理模块沉淀) + +以下要点来自代理模块开发,对后续涉及子进程管理、外部 API 交互、shadcn-vue 组件使用的模块同样适用。 + +### Tauri 命令与主线程 + +- **同步命令(`pub fn`)会阻塞主线程**:Tauri 的同步命令在主线程执行,其内部的 `std::thread::sleep`、磁盘 I/O、网络请求会阻塞所有 async 命令的调度。涉及等待/阻塞操作的命令必须声明为 `pub async fn`,并用 `tauri::async_runtime::spawn_blocking(|| { std::thread::sleep(...) }).await` 将阻塞操作放到线程池。 +- **Windows 端口释放有延迟**:`child.kill()` + `child.wait()` 后 TCP 端口不会立即可用,需等待约 800ms 再重新绑定。重启类命令应预留此延迟。 +- **`Mutex` 持锁期间禁止 sleep**:`ProcessManager::check_and_cleanup` 等持锁函数中不要执行长时间 sleep,否则会阻塞所有需要该锁的命令(如状态查询)。应先释放锁再 sleep,或移出临界区。 +- **进程监控线程**:`start_monitoring_thread` 每 3 秒检查一次进程状态,崩溃时自动重启(可配置 `maxRestarts`)。前端通过监听 `process-status-changed` 事件更新 UI。 + +### 外部 API 交互 + +- **API 就绪轮询**:子进程 spawn 后 API 不会立即可用(需初始化配置、加载 geo 文件等)。前端应在请求前轮询健康检查接口(如 `/version`),500ms 间隔、10s 超时。 +- **缓存配置避免频繁读盘**:后端 Manager 每次方法调用都从磁盘读 settings 会拖慢批量操作。建议在 Manager 内维护内存缓存,`save_settings` 时同步更新。 +- **并发请求限流**:批量测速等场景不要一次性 `Promise.all` 全部请求,应分批(如每批 20 个),避免压垮子进程。 + +### shadcn-vue / reka-ui 注意事项 + +- **Select 禁止空字符串 value**:`` 会触发警告并失效。使用哨兵值(如 `__default__`、`__all__`)替代空字符串,在 `@update:model-value` 回调中转回空值。 +- **Select 双击问题**:reka-ui Select 的 DismissableLayer + closeAutoFocus 会导致连续点击两个 Select 时第一次点击仅关闭上一个、需第二次点击才打开下一个。当前未完美解决,建议同一界面避免放置过多相邻 Select。 +- **Switch 使用 `model-value` / `update:model-value`**:reka-ui v2+ 的 Switch 不再使用 `checked` / `update:checked`。 +- **Sonner toast 不可见**:通常是缺少 `vue-sonner/lib/index.css` 导入,而非 z-index 问题。确保在入口处导入该 CSS。 +- **AlertDialog 替代 confirm()**:原生 `confirm()` 在 Tauri WebView 中样式不一致,使用 shadcn-vue AlertDialog 封装 Promise 化的 `showConfirm()` 函数,支持 destructive 样式。 + +### 数据一致性与自愈 + +- **配置文件与磁盘 reconcile**:settings.json 中的列表(如订阅 profiles)可能与磁盘文件不同步(用户手动删除、反序列化失败被 default 覆盖)。每次 `load_settings` 时应扫描磁盘补全缺失条目,并在 `currentProfile` 为 null 时自动指向第一个。 +- **操作顺序防重复**:导入文件后写入 settings 时,若先写文件再 load_settings(含 reconcile 扫盘),reconcile 会扫到新文件添加一次,随后 push 又添加一次。应先 load_settings → 写文件 → push(加去重保护)。 +- **子进程崩溃循环防护**:子进程因依赖文件损坏(如 geo 数据库)崩溃时,`restart_on_crash` 会反复重启。需配置备用下载源(如 jsdelivr 镜像)避免无代理时 GitHub 超时。 + +### UI 细节 + +- **瀑布流布局避免卡片等高撑开**:使用 `columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid` 替代 `grid`,让卡片按内容高度自然排列。 +- **模块禁用清理系统状态**:模块 `onDisable` 钩子应清理系统级副作用(如系统代理注册表项),避免模块停用后遗留导致系统异常。 +- **应用退出清理**:在 `lib.rs` 的 `quit_app` 命令和托盘退出事件中都要调用 `cleanup_on_exit` + `stop_all`,确保任何退出路径都清理干净。 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 845aefd..70bde2d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "opener:default", + "opener:allow-reveal-item-in-dir", "core:window:allow-minimize", "core:window:allow-maximize", "core:window:allow-close", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bfa37a..d7e7ff0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,11 +8,11 @@ use logger::{ clear_logs, get_log_info, get_logs, log_message, LogManager, }; use mihomo_manager::{ - proxy_activate_profile, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile, + proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, - proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager, + proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager, }; use process_manager::{ get_all_process_status, get_process_status, start_monitoring_thread, start_process, @@ -25,8 +25,13 @@ fn greet(name: &str) -> String { } #[tauri::command] -fn quit_app(state: tauri::State<'_, ProcessManager>) { - // 退出前停止所有子进程 +fn quit_app( + state: tauri::State<'_, ProcessManager>, + mihomo: tauri::State<'_, MihomoManager>, +) { + // 退出前清理系统代理,避免遗留导致网络问题 + mihomo.cleanup_on_exit(); + // 停止所有子进程 state.stop_all(); std::process::exit(0); } @@ -52,6 +57,8 @@ pub fn run() { proxy_get_settings, proxy_save_settings, proxy_kernel_info, + proxy_check_kernel_update, + proxy_update_kernel, proxy_status, proxy_start, proxy_stop, @@ -85,7 +92,8 @@ pub fn run() { .path() .app_data_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")); - app.manage(MihomoManager::new(app_data_dir)); + let mihomo = MihomoManager::new(app_data_dir); + app.manage(mihomo); let open = tauri::menu::MenuItem::with_id(app, "open", "设置", true, None::<&str>)?; let quit = tauri::menu::MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; @@ -103,7 +111,10 @@ pub fn run() { } } "quit" => { - // 退出前停止所有子进程 + // 退出前清理系统代理 + 停止所有子进程 + if let Some(mihomo) = app.try_state::() { + mihomo.cleanup_on_exit(); + } if let Some(pm) = app.try_state::() { pm.stop_all(); } @@ -129,6 +140,13 @@ pub fn run() { // 启动进程监控线程 start_monitoring_thread(app.handle().clone()); + // 应用启动时自动启动 mihomo(如果用户在设置中开启了自动启动) + if let Some(mihomo) = app.try_state::() { + if let Some(pm) = app.try_state::() { + mihomo.auto_start_on_launch(app.handle(), &pm); + } + } + Ok(()) }) .on_window_event(|window, event| { diff --git a/src-tauri/src/mihomo_manager.rs b/src-tauri/src/mihomo_manager.rs index 59198b1..2c4b228 100644 --- a/src-tauri/src/mihomo_manager.rs +++ b/src-tauri/src/mihomo_manager.rs @@ -14,18 +14,44 @@ use crate::process_manager::{ProcessInfo, ProcessManager, StartProcessParams}; #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ProxySettings { + #[serde(default = "default_mixed_port")] pub mixed_port: u16, + #[serde(default = "default_external_controller")] pub external_controller: String, + #[serde(default)] pub secret: String, + #[serde(default = "default_mode")] pub mode: String, + #[serde(default = "default_log_level")] pub log_level: String, + #[serde(default)] pub allow_lan: bool, + #[serde(default)] pub system_proxy: bool, + #[serde(default)] pub auto_start: bool, + #[serde(default)] + pub auto_system_proxy: bool, + #[serde(default)] pub current_profile: Option, + #[serde(default)] pub profiles: Vec, + #[serde(default)] + pub auto_switch_enabled: bool, + #[serde(default = "default_auto_switch_interval")] + pub auto_switch_interval: u32, + #[serde(default)] + pub auto_switch_group: String, + #[serde(default)] + pub auto_switch_region: String, } +fn default_mixed_port() -> u16 { 7890 } +fn default_external_controller() -> String { "127.0.0.1:9090".into() } +fn default_mode() -> String { "rule".into() } +fn default_log_level() -> String { "info".into() } +fn default_auto_switch_interval() -> u32 { 5 } + impl Default for ProxySettings { fn default() -> Self { Self { @@ -37,8 +63,13 @@ impl Default for ProxySettings { allow_lan: false, system_proxy: false, auto_start: false, + auto_system_proxy: false, current_profile: None, profiles: Vec::new(), + auto_switch_enabled: false, + auto_switch_interval: 5, + auto_switch_group: String::new(), + auto_switch_region: String::new(), } } } @@ -46,11 +77,17 @@ impl Default for ProxySettings { #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ProfileMeta { + #[serde(default)] pub id: String, + #[serde(default)] pub name: String, + #[serde(default)] pub url: String, + #[serde(default)] pub added_at: String, + #[serde(default)] pub updated_at: String, + #[serde(default)] pub size: u64, } @@ -62,6 +99,15 @@ pub struct KernelInfo { pub version: Option, } +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KernelUpdateInfo { + pub current_version: Option, + pub latest_version: String, + pub download_url: String, + pub has_update: bool, +} + #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ProxyStatus { @@ -116,10 +162,68 @@ impl MihomoManager { // ---------- 设置 ---------- pub fn load_settings(&self) -> ProxySettings { - fs::read_to_string(self.settings_path()) + let mut settings = fs::read_to_string(self.settings_path()) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) - .unwrap_or_default() + .unwrap_or_default(); + // 恢复机制:扫描磁盘 profile 文件,补全 settings.profiles + // 防止 settings.json 损坏(如反序列化失败被 default 覆盖)导致订阅丢失 + if self.reconcile_profiles(&mut settings) { + let _ = self.save_settings(&settings); + } + settings + } + + /// 扫描磁盘 profile 文件,补全 settings.profiles 中缺失的条目。 + /// 返回 true 表示有变化需要保存。 + fn reconcile_profiles(&self, settings: &mut ProxySettings) -> bool { + let mut changed = false; + let existing_ids: std::collections::HashSet = + settings.profiles.iter().map(|p| p.id.clone()).collect(); + + if let Ok(entries) = fs::read_dir(self.profiles_dir()) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + continue; + } + let Some(id) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + else { + continue; + }; + if existing_ids.contains(&id) { + continue; + } + let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let updated_at = fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_default(); + settings.profiles.push(ProfileMeta { + added_at: updated_at.clone(), + id: id.clone(), + name: id, + url: String::new(), + updated_at, + size, + }); + changed = true; + } + } + + // 如果 currentProfile 为 null 但有 profile,设置为第一个 + if settings.current_profile.is_none() && !settings.profiles.is_empty() { + settings.current_profile = Some(settings.profiles[0].id.clone()); + changed = true; + } + + changed } pub fn save_settings(&self, settings: &ProxySettings) -> Result<(), String> { @@ -165,6 +269,141 @@ impl MihomoManager { Ok(self.kernel_info()) } + /// 检查 GitHub 上的最新 mihomo 版本 + pub async fn check_kernel_update(&self) -> Result { + let resp: serde_json::Value = self + .client + .get("https://api.github.com/repos/MetaCubeX/mihomo/releases/latest") + .header("User-Agent", "thing-app") + .send() + .await + .map_err(|e| format!("请求 GitHub API 失败: {}", e))? + .json() + .await + .map_err(|e| format!("解析 GitHub 响应失败: {}", e))?; + + let latest_version = resp + .get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + // 查找 windows amd64 zip 资产(非 compatible 版本) + let download_url = resp + .get("assets") + .and_then(|a| a.as_array()) + .and_then(|assets| { + assets.iter().find_map(|asset| { + let name = asset.get("name")?.as_str()?; + let url = asset.get("browser_download_url")?.as_str()?; + // 匹配 mihomo-windows-amd64-v*.zip,排除 compatible/arm64 + if name.starts_with("mihomo-windows-amd64-") + && name.ends_with(".zip") + && !name.contains("compatible") + && !name.contains("arm64") + { + Some(url.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| "未找到适用的 Windows amd64 内核资产".to_string())?; + + // 当前版本 + let current = self.kernel_info().version; + let has_update = match ¤t { + Some(c) => { + // 简单比较:从当前版本字符串提取版本号 + let cur_ver = c + .split_whitespace() + .find(|s| s.starts_with('v') && s.chars().filter(|c| *c == '.').count() >= 2) + .unwrap_or(""); + cur_ver != latest_version && !latest_version.is_empty() + } + None => true, + }; + + Ok(KernelUpdateInfo { + current_version: current, + latest_version, + download_url, + has_update, + }) + } + + /// 下载并安装内核更新 + pub async fn update_kernel(&self) -> Result { + let info = self.check_kernel_update().await?; + let zip_path = self.cores_dir().join("mihomo-update.zip"); + let extract_dir = self.cores_dir().join("mihomo-update-tmp"); + + // 下载 zip + let resp = self + .client + .get(&info.download_url) + .header("User-Agent", "thing-app") + .send() + .await + .map_err(|e| format!("下载内核失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("下载失败: HTTP {}", resp.status())); + } + let bytes = resp + .bytes() + .await + .map_err(|e| format!("读取下载内容失败: {}", e))?; + fs::write(&zip_path, &bytes).map_err(|e| format!("保存 zip 失败: {}", e))?; + + // 清理旧解压目录 + if extract_dir.exists() { + fs::remove_dir_all(&extract_dir).ok(); + } + fs::create_dir_all(&extract_dir).map_err(|e| e.to_string())?; + + // 用 PowerShell 解压 + let output = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!( + "Expand-Archive -Path '{}' -DestinationPath '{}' -Force", + zip_path.to_string_lossy(), + extract_dir.to_string_lossy() + ), + ]) + .output() + .map_err(|e| format!("解压失败: {}", e))?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + return Err(format!("解压失败: {}", err)); + } + + // 查找解压出的 mihomo.exe + let new_exe = extract_dir.join("mihomo.exe"); + if !new_exe.exists() { + // 可能在不同子目录 + return Err("解压后未找到 mihomo.exe".into()); + } + + // 备份旧内核 + let kernel = self.kernel_path(); + if kernel.exists() { + let bak = self.cores_dir().join("mihomo.exe.bak"); + fs::remove_file(&bak).ok(); + fs::rename(&kernel, &bak).map_err(|e| format!("备份旧内核失败: {}", e))?; + } + + // 移动新内核 + fs::rename(&new_exe, &kernel).map_err(|e| format!("替换内核失败: {}", e))?; + + // 清理临时文件 + fs::remove_file(&zip_path).ok(); + fs::remove_dir_all(&extract_dir).ok(); + + Ok(self.kernel_info()) + } + // ---------- 配置生成 ---------- /// 合并 profile + 控制器设置,生成运行时 config.yaml pub fn generate_config(&self) -> Result<(), String> { @@ -200,6 +439,29 @@ impl MihomoManager { ); m.insert(YamlValue::String("allow-lan".into()), YamlValue::Bool(settings.allow_lan)); + // 日志写入文件,便于排查问题 + let log_file = self.logs_dir().join("mihomo.log"); + m.insert( + YamlValue::String("log-file".into()), + YamlValue::String(log_file.to_string_lossy().to_string()), + ); + + // Geo 数据库下载源(使用 jsdelivr 国内可访问镜像,避免无代理时 GitHub 超时) + let mut geox = serde_yaml::Mapping::new(); + geox.insert( + YamlValue::String("mmdb".into()), + YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb".into()), + ); + geox.insert( + YamlValue::String("geosite".into()), + YamlValue::String("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat".into()), + ); + geox.insert( + YamlValue::String("asn".into()), + YamlValue::String("https://cdn.jsdelivr.net/gh/xishang0128/bdg@master/GeoLite2-ASN.mmdb".into()), + ); + m.insert(YamlValue::String("geox-url".into()), YamlValue::Mapping(geox)); + let yaml = serde_yaml::to_string(&value).map_err(|e| e.to_string())?; fs::write(self.config_path(), yaml).map_err(|e| e.to_string())?; Ok(()) @@ -231,8 +493,44 @@ impl MihomoManager { }) } + /// 应用启动时检查是否需要自动启动 mihomo 和系统代理 + pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) { + let settings = self.load_settings(); + if !settings.auto_start { + return; + } + match self.prepare_for_start(app) { + Ok(params) => { + if let Err(e) = pm.start(params) { + eprintln!("[mihomo] 自动启动失败: {}", e); + } else if settings.auto_system_proxy { + // 启动成功后开启系统代理 + let addr = format!("127.0.0.1:{}", settings.mixed_port); + let _ = set_system_proxy_windows(&addr); + let mut s = settings; + s.system_proxy = true; + let _ = self.save_settings(&s); + } + } + Err(e) => { + eprintln!("[mihomo] 自动启动跳过: {}", e); + } + } + } + + /// 应用退出时清理:关闭系统代理 + pub fn cleanup_on_exit(&self) { + let settings = self.load_settings(); + if settings.system_proxy || settings.auto_system_proxy { + let _ = clear_system_proxy_windows(); + } + } + // ---------- 订阅管理 ---------- pub async fn import_profile(&self, url: &str, name: &str) -> Result { + // 先读取当前 settings(此时新 profile 文件还未写入,reconcile 不会误添加) + let mut settings = self.load_settings(); + let resp = self .client .get(url) @@ -259,8 +557,10 @@ impl MihomoManager { updated_at: now, size: content.len() as u64, }; - let mut settings = self.load_settings(); - settings.profiles.push(meta.clone()); + // 去重保护:避免 reconcile 已添加同 id(理论上不会,因为文件刚写入) + if !settings.profiles.iter().any(|p| p.id == id) { + settings.profiles.push(meta.clone()); + } if settings.current_profile.is_none() { settings.current_profile = Some(id); } @@ -540,6 +840,18 @@ pub fn proxy_kernel_info( state.prepare_kernel(&app) } +#[tauri::command] +pub async fn proxy_check_kernel_update( + state: tauri::State<'_, MihomoManager>, +) -> Result { + state.check_kernel_update().await +} + +#[tauri::command] +pub async fn proxy_update_kernel(state: tauri::State<'_, MihomoManager>) -> Result { + state.update_kernel().await +} + #[tauri::command] pub fn proxy_status(pm: tauri::State<'_, ProcessManager>) -> ProxyStatus { match pm.get_status("proxy") { @@ -572,12 +884,18 @@ pub fn proxy_stop(pm: tauri::State<'_, ProcessManager>) -> Result<(), String> { } #[tauri::command] -pub fn proxy_restart( +pub async fn proxy_restart( state: tauri::State<'_, MihomoManager>, pm: tauri::State<'_, ProcessManager>, app: AppHandle, ) -> Result { let _ = pm.stop("proxy"); + // 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟),在阻塞线程池中 sleep 避免阻塞主线程 + tauri::async_runtime::spawn_blocking(|| { + std::thread::sleep(std::time::Duration::from_millis(800)); + }) + .await + .map_err(|e| format!("sleep 失败: {}", e))?; let params = state.prepare_for_start(&app)?; pm.start(params) } diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 8233165..79a8346 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -228,6 +228,8 @@ impl ProcessManager { // 先终止旧进程 let _ = entry.child.kill(); let _ = entry.child.wait(); + // 等待 TCP 端口释放(Windows 上 kill 后端口释放有延迟) + std::thread::sleep(std::time::Duration::from_millis(800)); // 重新启动 let mut cmd = Command::new(&executable); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4985c6d..d6bf499 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -36,6 +36,7 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": ["binaries/*"] } } diff --git a/src/App.vue b/src/App.vue index 6927fda..9a2b580 100644 --- a/src/App.vue +++ b/src/App.vue @@ -108,6 +108,6 @@ onMounted(() => { - + diff --git a/src/components/ui/accordion/Accordion.vue b/src/components/ui/accordion/Accordion.vue new file mode 100644 index 0000000..58ad491 --- /dev/null +++ b/src/components/ui/accordion/Accordion.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/accordion/AccordionContent.vue b/src/components/ui/accordion/AccordionContent.vue new file mode 100644 index 0000000..91c8dcc --- /dev/null +++ b/src/components/ui/accordion/AccordionContent.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/accordion/AccordionItem.vue b/src/components/ui/accordion/AccordionItem.vue new file mode 100644 index 0000000..5eccc4c --- /dev/null +++ b/src/components/ui/accordion/AccordionItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/accordion/AccordionTrigger.vue b/src/components/ui/accordion/AccordionTrigger.vue new file mode 100644 index 0000000..81033d0 --- /dev/null +++ b/src/components/ui/accordion/AccordionTrigger.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ui/accordion/index.ts b/src/components/ui/accordion/index.ts new file mode 100644 index 0000000..b18018b --- /dev/null +++ b/src/components/ui/accordion/index.ts @@ -0,0 +1,4 @@ +export { default as Accordion } from "./Accordion.vue" +export { default as AccordionContent } from "./AccordionContent.vue" +export { default as AccordionItem } from "./AccordionItem.vue" +export { default as AccordionTrigger } from "./AccordionTrigger.vue" diff --git a/src/components/ui/alert-dialog/AlertDialog.vue b/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..b6e6b4b --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogAction.vue b/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..09cf6fc --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogCancel.vue b/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..e261894 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogContent.vue b/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..4597f0d --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogDescription.vue b/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..69642c9 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogFooter.vue b/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..50d4098 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogHeader.vue b/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..dbe72a7 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogTitle.vue b/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..bb97e4d --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/src/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..98d40ee --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/alert-dialog/index.ts b/src/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..cf1b45d --- /dev/null +++ b/src/components/ui/alert-dialog/index.ts @@ -0,0 +1,9 @@ +export { default as AlertDialog } from "./AlertDialog.vue" +export { default as AlertDialogAction } from "./AlertDialogAction.vue" +export { default as AlertDialogCancel } from "./AlertDialogCancel.vue" +export { default as AlertDialogContent } from "./AlertDialogContent.vue" +export { default as AlertDialogDescription } from "./AlertDialogDescription.vue" +export { default as AlertDialogFooter } from "./AlertDialogFooter.vue" +export { default as AlertDialogHeader } from "./AlertDialogHeader.vue" +export { default as AlertDialogTitle } from "./AlertDialogTitle.vue" +export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue" diff --git a/src/components/ui/alert/Alert.vue b/src/components/ui/alert/Alert.vue new file mode 100644 index 0000000..a9d336f --- /dev/null +++ b/src/components/ui/alert/Alert.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/alert/AlertDescription.vue b/src/components/ui/alert/AlertDescription.vue new file mode 100644 index 0000000..652f332 --- /dev/null +++ b/src/components/ui/alert/AlertDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/alert/AlertTitle.vue b/src/components/ui/alert/AlertTitle.vue new file mode 100644 index 0000000..b218384 --- /dev/null +++ b/src/components/ui/alert/AlertTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/alert/index.ts b/src/components/ui/alert/index.ts new file mode 100644 index 0000000..42d07b6 --- /dev/null +++ b/src/components/ui/alert/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Alert } from "./Alert.vue" +export { default as AlertDescription } from "./AlertDescription.vue" +export { default as AlertTitle } from "./AlertTitle.vue" + +export const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type AlertVariants = VariantProps diff --git a/src/components/ui/badge/Badge.vue b/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..948b453 --- /dev/null +++ b/src/components/ui/badge/Badge.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts new file mode 100644 index 0000000..e76c1b4 --- /dev/null +++ b/src/components/ui/badge/index.ts @@ -0,0 +1,23 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-1.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: "border-transparent bg-destructive text-white", + outline: "text-foreground border-border", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export type BadgeVariants = VariantProps diff --git a/src/components/ui/combobox/Combobox.vue b/src/components/ui/combobox/Combobox.vue new file mode 100644 index 0000000..0e6907c --- /dev/null +++ b/src/components/ui/combobox/Combobox.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/combobox/ComboboxAnchor.vue b/src/components/ui/combobox/ComboboxAnchor.vue new file mode 100644 index 0000000..5b9041d --- /dev/null +++ b/src/components/ui/combobox/ComboboxAnchor.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/combobox/ComboboxEmpty.vue b/src/components/ui/combobox/ComboboxEmpty.vue new file mode 100644 index 0000000..20beb63 --- /dev/null +++ b/src/components/ui/combobox/ComboboxEmpty.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/combobox/ComboboxGroup.vue b/src/components/ui/combobox/ComboboxGroup.vue new file mode 100644 index 0000000..0dc9fc5 --- /dev/null +++ b/src/components/ui/combobox/ComboboxGroup.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/combobox/ComboboxInput.vue b/src/components/ui/combobox/ComboboxInput.vue new file mode 100644 index 0000000..b3bbde6 --- /dev/null +++ b/src/components/ui/combobox/ComboboxInput.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/ui/combobox/ComboboxItem.vue b/src/components/ui/combobox/ComboboxItem.vue new file mode 100644 index 0000000..cccf98c --- /dev/null +++ b/src/components/ui/combobox/ComboboxItem.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/combobox/ComboboxItemIndicator.vue b/src/components/ui/combobox/ComboboxItemIndicator.vue new file mode 100644 index 0000000..2e1b07c --- /dev/null +++ b/src/components/ui/combobox/ComboboxItemIndicator.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/combobox/ComboboxList.vue b/src/components/ui/combobox/ComboboxList.vue new file mode 100644 index 0000000..64c9a84 --- /dev/null +++ b/src/components/ui/combobox/ComboboxList.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/combobox/ComboboxSeparator.vue b/src/components/ui/combobox/ComboboxSeparator.vue new file mode 100644 index 0000000..05d648b --- /dev/null +++ b/src/components/ui/combobox/ComboboxSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/combobox/ComboboxTrigger.vue b/src/components/ui/combobox/ComboboxTrigger.vue new file mode 100644 index 0000000..28b099a --- /dev/null +++ b/src/components/ui/combobox/ComboboxTrigger.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/combobox/ComboboxViewport.vue b/src/components/ui/combobox/ComboboxViewport.vue new file mode 100644 index 0000000..684085c --- /dev/null +++ b/src/components/ui/combobox/ComboboxViewport.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/combobox/index.ts b/src/components/ui/combobox/index.ts new file mode 100644 index 0000000..5e599b9 --- /dev/null +++ b/src/components/ui/combobox/index.ts @@ -0,0 +1,13 @@ +export { default as Combobox } from "./Combobox.vue" +export { default as ComboboxAnchor } from "./ComboboxAnchor.vue" +export { default as ComboboxEmpty } from "./ComboboxEmpty.vue" +export { default as ComboboxGroup } from "./ComboboxGroup.vue" +export { default as ComboboxInput } from "./ComboboxInput.vue" +export { default as ComboboxItem } from "./ComboboxItem.vue" +export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue" +export { default as ComboboxList } from "./ComboboxList.vue" +export { default as ComboboxSeparator } from "./ComboboxSeparator.vue" +export { default as ComboboxTrigger } from "./ComboboxTrigger.vue" +export { default as ComboboxViewport } from "./ComboboxViewport.vue" + +export { ComboboxCancel } from "reka-ui" diff --git a/src/components/ui/dialog/Dialog.vue b/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ade5260 --- /dev/null +++ b/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/dialog/DialogClose.vue b/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..c5fae04 --- /dev/null +++ b/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dialog/DialogContent.vue b/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..05172f0 --- /dev/null +++ b/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/ui/dialog/DialogDescription.vue b/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..f52e655 --- /dev/null +++ b/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dialog/DialogFooter.vue b/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..c7500a9 --- /dev/null +++ b/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/dialog/DialogHeader.vue b/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..bfc3c64 --- /dev/null +++ b/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/dialog/DialogOverlay.vue b/src/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 0000000..7790077 --- /dev/null +++ b/src/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/dialog/DialogScrollContent.vue b/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..abfb3a1 --- /dev/null +++ b/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/ui/dialog/DialogTitle.vue b/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..860f01a --- /dev/null +++ b/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dialog/DialogTrigger.vue b/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..49667e9 --- /dev/null +++ b/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dialog/index.ts b/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..6768b09 --- /dev/null +++ b/src/components/ui/dialog/index.ts @@ -0,0 +1,10 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogOverlay } from "./DialogOverlay.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/src/components/ui/select/Select.vue b/src/components/ui/select/Select.vue new file mode 100644 index 0000000..c94bbe8 --- /dev/null +++ b/src/components/ui/select/Select.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/select/SelectContent.vue b/src/components/ui/select/SelectContent.vue new file mode 100644 index 0000000..1dc29a0 --- /dev/null +++ b/src/components/ui/select/SelectContent.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/ui/select/SelectGroup.vue b/src/components/ui/select/SelectGroup.vue new file mode 100644 index 0000000..e981c6c --- /dev/null +++ b/src/components/ui/select/SelectGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/select/SelectItem.vue b/src/components/ui/select/SelectItem.vue new file mode 100644 index 0000000..a6a0dd3 --- /dev/null +++ b/src/components/ui/select/SelectItem.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/ui/select/SelectItemText.vue b/src/components/ui/select/SelectItemText.vue new file mode 100644 index 0000000..b6700b1 --- /dev/null +++ b/src/components/ui/select/SelectItemText.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/select/SelectLabel.vue b/src/components/ui/select/SelectLabel.vue new file mode 100644 index 0000000..5b6650c --- /dev/null +++ b/src/components/ui/select/SelectLabel.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/select/SelectScrollDownButton.vue b/src/components/ui/select/SelectScrollDownButton.vue new file mode 100644 index 0000000..62c9c9e --- /dev/null +++ b/src/components/ui/select/SelectScrollDownButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/select/SelectScrollUpButton.vue b/src/components/ui/select/SelectScrollUpButton.vue new file mode 100644 index 0000000..b27afe8 --- /dev/null +++ b/src/components/ui/select/SelectScrollUpButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/select/SelectSeparator.vue b/src/components/ui/select/SelectSeparator.vue new file mode 100644 index 0000000..4b5c885 --- /dev/null +++ b/src/components/ui/select/SelectSeparator.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/select/SelectTrigger.vue b/src/components/ui/select/SelectTrigger.vue new file mode 100644 index 0000000..3715b43 --- /dev/null +++ b/src/components/ui/select/SelectTrigger.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/select/SelectValue.vue b/src/components/ui/select/SelectValue.vue new file mode 100644 index 0000000..d5ce58b --- /dev/null +++ b/src/components/ui/select/SelectValue.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/select/index.ts b/src/components/ui/select/index.ts new file mode 100644 index 0000000..96eae60 --- /dev/null +++ b/src/components/ui/select/index.ts @@ -0,0 +1,11 @@ +export { default as Select } from "./Select.vue" +export { default as SelectContent } from "./SelectContent.vue" +export { default as SelectGroup } from "./SelectGroup.vue" +export { default as SelectItem } from "./SelectItem.vue" +export { default as SelectItemText } from "./SelectItemText.vue" +export { default as SelectLabel } from "./SelectLabel.vue" +export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue" +export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue" +export { default as SelectSeparator } from "./SelectSeparator.vue" +export { default as SelectTrigger } from "./SelectTrigger.vue" +export { default as SelectValue } from "./SelectValue.vue" diff --git a/src/main.ts b/src/main.ts index bcf10e3..5c45166 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import './style.css' +import 'vue-sonner/style.css' // 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry import './modules' diff --git a/src/modules/proxy/ProxyModule.vue b/src/modules/proxy/ProxyModule.vue index 7bdd03a..5c2be5b 100644 --- a/src/modules/proxy/ProxyModule.vue +++ b/src/modules/proxy/ProxyModule.vue @@ -2,22 +2,71 @@ import { Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2, Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks, - Upload, Link2, Loader2 + Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy } from '@lucide/vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' import { invoke } from '@tauri-apps/api/core' +import { appDataDir } from '@tauri-apps/api/path' +import { revealItemInDir } from '@tauri-apps/plugin-opener' import { useProxyStore, type ProxyNode } from '@/stores/proxyStore' +import { createLogger } from '@/lib/logger' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue +} from '@/components/ui/select' +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, + AlertDialogContent, AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, AlertDialogTitle +} from '@/components/ui/alert-dialog' const store = useProxyStore() +const logger = createLogger('proxy') + +// ===== 通用确认对话框(替代原生 confirm) ===== +interface ConfirmOptions { + title: string + description: string + confirmText?: string + cancelText?: string + destructive?: boolean +} +const confirmState = ref<{ + open: boolean + opts: ConfirmOptions + resolved: boolean + resolve?: (v: boolean) => void +}>({ open: false, opts: { title: '', description: '' }, resolved: false }) + +const showConfirm = (opts: ConfirmOptions): Promise => { + return new Promise((resolve) => { + confirmState.value = { open: true, opts, resolved: false, resolve } + }) +} +const onConfirmAction = () => { + if (confirmState.value.resolved) return + confirmState.value.resolved = true + confirmState.value.resolve?.(true) +} +const onConfirmCancel = () => { + if (confirmState.value.resolved) return + confirmState.value.resolved = true + confirmState.value.resolve?.(false) +} +const onConfirmOpenChange = (open: boolean) => { + if (!open) onConfirmCancel() + confirmState.value.open = open +} const activeTab = ref('overview') const starting = ref(false) @@ -28,18 +77,129 @@ const importUrl = ref('') const importName = ref('') const importing = ref(false) const testingGroups = ref>(new Set()) +const loadingProxies = ref(false) +const checkingUpdate = ref(false) +const updatingKernel = ref(false) +const kernelUpdateInfo = ref<{ latestVersion: string; hasUpdate: boolean } | null>(null) + +// 自动切换节点(从 settings 持久化) +const autoSwitchEnabled = ref(false) +const autoSwitchInterval = ref(5) // 分钟 +const autoSwitchTargetGroup = ref('') // 目标代理组 +const autoSwitchRegion = ref('') // 空=全部,否则按地区过滤 +let autoSwitchTimer: ReturnType | null = null + +// 从 store.settings 同步自动切换设置 +const syncAutoSwitchSettings = () => { + if (store.settings) { + autoSwitchEnabled.value = store.settings.autoSwitchEnabled + autoSwitchInterval.value = store.settings.autoSwitchInterval + autoSwitchTargetGroup.value = store.settings.autoSwitchGroup + autoSwitchRegion.value = store.settings.autoSwitchRegion + } +} + +// 保存自动切换设置到 settings.json +const saveAutoSwitchSettings = async () => { + if (!store.settings) return + try { + await store.saveSettings({ + ...store.settings, + autoSwitchEnabled: autoSwitchEnabled.value, + autoSwitchInterval: autoSwitchInterval.value, + autoSwitchGroup: autoSwitchTargetGroup.value, + autoSwitchRegion: autoSwitchRegion.value + }) + } catch (e) { + logger.error('保存自动切换设置失败: ' + e) + } +} + +// 手风琴展开项 +const accordionValue = ref('') // 进程状态轮询 let statusTimer: ReturnType | null = null const running = computed(() => store.status.running) +// 伪节点关键词:DIRECT/REJECT/流量/套餐等非具体代理节点 +const PSEUDO_NODE_KEYWORDS = [ + 'DIRECT', 'REJECT', 'PASS', 'COMPATIBLE', + '流量', '套餐', '到期', '续费', '官网', '网站', '刷新', '更新', + 'x', '×', '✕', '✖', '⭐', '★', '☆', + 'GLOBAL', ' GLOBAL' +] + +const isPseudoNode = (name: string): boolean => { + const upper = name.toUpperCase().trim() + if (upper === 'DIRECT' || upper === 'REJECT' || upper === 'PASS') return true + return PSEUDO_NODE_KEYWORDS.some(kw => name.includes(kw)) +} + // 代理组(Selector/URLTest/Fallback/LoadBalance) const GROUP_TYPES = ['Selector', 'URLTest', 'Fallback', 'LoadBalance'] const groups = computed>(() => { return Object.entries(store.proxies).filter(([, n]) => GROUP_TYPES.includes(n.type)) }) +// Selector 组(可手动切换) +const selectorGroups = computed>(() => { + return Object.entries(store.proxies).filter(([, n]) => n.type === 'Selector') +}) + +// 主 Selector 组(第一个,用于快捷切换和自动切换) +const mainGroup = computed<[string, ProxyNode] | null>(() => { + // 优先找名为 PROXY/节点选择/Proxy 的组 + const preferred = selectorGroups.value.find(([name]) => + ['PROXY', 'Proxy', '节点选择', '代理'].includes(name) + ) + return preferred ?? selectorGroups.value[0] ?? null +}) + +const mainGroupName = computed(() => mainGroup.value?.[0] ?? '') +const mainGroupNow = computed(() => mainGroup.value?.[1]?.now ?? '') +const mainGroupNodes = computed(() => mainGroup.value?.[1]?.all ?? []) + +// 当前节点延迟 +const currentNodeDelay = computed(() => { + if (!mainGroupNow.value) return undefined + return store.proxies[mainGroupNow.value]?.history?.[0]?.delay +}) + +// 从节点名提取地区(如 "🇯🇵日本东京01" → "🇯🇵日本东京") +const extractRegion = (name: string): string => { + // 在数字、竖线、横线、括号前截断 + const m = name.split(/[\d|\--—((【]/)[0] + return m?.trim() || name +} + +// 所有可选地区列表(从目标组节点提取,过滤伪节点) +const regionOptions = computed(() => { + const regions = new Set() + const nodes = autoSwitchTargetGroupNodes.value + for (const node of nodes) { + if (isPseudoNode(node)) continue + const region = extractRegion(node) + if (region) regions.add(region) + } + return Array.from(regions).sort() +}) + +// 目标代理组的有效节点(过滤伪节点) +const autoSwitchTargetGroupNodes = computed(() => { + const groupName = autoSwitchTargetGroup.value || mainGroupName.value + const group = store.proxies[groupName] + return group?.all?.filter(n => !isPseudoNode(n)) ?? [] +}) + +// 按地区过滤的节点 +const filteredNodes = computed(() => { + const nodes = autoSwitchTargetGroupNodes.value + if (!autoSwitchRegion.value) return nodes + return nodes.filter(n => extractRegion(n) === autoSwitchRegion.value) +}) + const modeOptions = [ { value: 'rule', label: '规则' }, { value: 'global', label: '全局' }, @@ -56,11 +216,61 @@ const formatSize = (bytes: number) => { return `${(bytes / 1024 / 1024).toFixed(2)} MB` } -const delayColor = (delay: number | undefined) => { - if (!delay) return 'text-muted-foreground' - if (delay < 150) return 'text-emerald-500' - if (delay < 400) return 'text-amber-500' - return 'text-red-500' +const versionShort = computed(() => { + const v = store.kernel?.version + if (!v) return '—' + const m = v.match(/v\d+\.\d+\.\d+/) + return m ? m[0] : v.split(/\s+/).slice(0, 3).join(' ') +}) + +const pathShort = computed(() => { + const p = store.kernel?.path + if (!p) return '—' + const parts = p.replace(/\\/g, '/').split('/') + if (parts.length <= 4) return p + return '.../' + parts.slice(-3).join('/') +}) + +// appData 路径(用于将完整路径中的 appData 部分替换为 %APPDATA%) +const appDataPath = ref('') +// 内核完整路径(带 %APPDATA% 环境变量形式,可复制到文件管理器地址栏打开) +const pathDisplay = computed(() => { + const p = store.kernel?.path + if (!p) return '' + if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) { + return '%APPDATA%' + p.slice(appDataPath.value.length) + } + return p +}) + +const copyPath = async () => { + const p = store.kernel?.path + if (!p) return + try { + await navigator.clipboard.writeText(p) + toast.success('路径已复制', { description: pathDisplay.value }) + } catch { + toast.error('复制失败') + } +} + +const openFolder = async () => { + const p = store.kernel?.path + if (!p) return + try { + await revealItemInDir(p) + } catch (e) { + toast.error('打开文件夹失败', { description: String(e) }) + } +} + +// 延迟 Badge 样式(超时用醒目红色) +const delayBadgeClass = (delay: number | undefined) => { + if (delay === undefined) return 'border-transparent bg-muted text-muted-foreground' + if (delay === 0) return 'border-transparent bg-red-500 text-white' + if (delay < 150) return 'border-transparent bg-emerald-100 text-emerald-700' + if (delay < 400) return 'border-transparent bg-amber-100 text-amber-700' + return 'border-transparent bg-orange-100 text-orange-700' } const delayText = (delay: number | undefined) => { @@ -70,11 +280,40 @@ const delayText = (delay: number | undefined) => { } // ===== 生命周期 ===== +const loadProxiesWithError = async () => { + loadingProxies.value = true + try { + await store.loadProxies() + // 默认展开第一个代理组 + if (groups.value.length && !accordionValue.value) { + accordionValue.value = groups.value[0][0] + } + } catch (e) { + logger.error('加载节点列表失败: ' + e) + toast.error('加载节点失败', { description: String(e) }) + } finally { + loadingProxies.value = false + } +} + const init = async () => { + // 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式 + try { + appDataPath.value = await appDataDir() + } catch { + /* 忽略 */ + } await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()]) + // 同步持久化的自动切换设置 + syncAutoSwitchSettings() if (running.value) { + await store.waitForApi() store.refreshVersion() - store.loadProxies().catch(() => {}) + loadProxiesWithError() + // 若自动切换已开启,恢复定时器 + if (autoSwitchEnabled.value) { + startAutoSwitch() + } } } @@ -87,13 +326,25 @@ onMounted(() => { onUnmounted(() => { if (statusTimer) clearInterval(statusTimer) + stopAutoSwitch() }) -// 状态从停止→运行时,加载节点与版本 watch(running, async (val, old) => { if (val && !old) { + await store.waitForApi() await store.refreshVersion() - await store.loadProxies().catch(() => {}) + await loadProxiesWithError() + } +}) + +// 切换到节点 Tab 时,加载节点列表并自动测速 +watch(activeTab, async (tab) => { + if (tab === 'proxies' && running.value) { + if (!Object.keys(store.proxies).length) { + await loadProxiesWithError() + } + // 自动对所有组测速一次 + autoTestAllGroups() } }) @@ -102,9 +353,14 @@ const handleStart = async () => { starting.value = true try { await store.start() + const ok = await store.waitForApi() + if (!ok) { + toast.error('mihomo 启动超时,API 无响应') + return + } toast.success('mihomo 已启动') await store.refreshVersion() - await store.loadProxies().catch(() => {}) + await loadProxiesWithError() } catch (e) { toast.error('启动失败', { description: String(e) }) } finally { @@ -115,6 +371,7 @@ const handleStart = async () => { const handleStop = async () => { stopping.value = true try { + stopAutoSwitch() await store.stop() toast.success('mihomo 已停止') } catch (e) { @@ -128,9 +385,14 @@ const handleRestart = async () => { restarting.value = true try { await store.restart() + const ok = await store.waitForApi() + if (!ok) { + toast.error('mihomo 重启超时,API 无响应') + return + } toast.success('mihomo 已重启') await store.refreshVersion() - await store.loadProxies().catch(() => {}) + await loadProxiesWithError() } catch (e) { toast.error('重启失败', { description: String(e) }) } finally { @@ -152,6 +414,9 @@ const onToggleSystemProxy = async (on: boolean) => { } // ===== 模式切换 ===== +const invokePatchConfigs = (body: Record) => + invoke('proxy_patch_configs', { body }) + const changeMode = async (mode: string) => { if (!store.settings || store.settings.mode === mode) return const prev = store.settings.mode @@ -168,15 +433,157 @@ const changeMode = async (mode: string) => { } } -const invokePatchConfigs = (body: Record) => - invoke('proxy_patch_configs', { body }) +// ===== 快捷切换节点 ===== +const quickSwitchNode = async (name: string) => { + if (!mainGroupName.value) return + try { + await store.selectProxy(mainGroupName.value, name) + toast.success('节点已切换', { description: name }) + // 测速新节点 + store.testDelay(name).then(delay => { + toast.success(`${name}`, { description: `延迟 ${delay}ms` }) + }).catch(() => {}) + } catch (e) { + toast.error('切换节点失败', { description: String(e) }) + } +} + +// ===== 自动切换节点 ===== +const startAutoSwitch = () => { + stopAutoSwitch() + if (!autoSwitchEnabled.value) return + const ms = autoSwitchInterval.value * 60 * 1000 + autoSwitchTimer = setInterval(runAutoSwitch, ms) + toast.success('自动切换已开启', { + description: `每 ${autoSwitchInterval.value} 分钟测试并切换至最优节点` + }) + // 立即执行一次 + runAutoSwitch() +} + +const stopAutoSwitch = () => { + if (autoSwitchTimer) { + clearInterval(autoSwitchTimer) + autoSwitchTimer = null + } +} + +const onToggleAutoSwitch = (on: boolean) => { + autoSwitchEnabled.value = on + if (on) { + startAutoSwitch() + } else { + stopAutoSwitch() + toast.info('自动切换已关闭') + } + saveAutoSwitchSettings() +} + +const onAutoSwitchIntervalChange = (val: unknown) => { + autoSwitchInterval.value = Number(val) || 5 + if (autoSwitchEnabled.value) { + startAutoSwitch() + } + saveAutoSwitchSettings() +} + +const runAutoSwitch = async () => { + const groupName = autoSwitchTargetGroup.value || mainGroupName.value + if (!groupName || !running.value) return + const nodes = filteredNodes.value + if (!nodes.length) return + + toast.info('正在测试节点延迟...') + try { + // 使用 testDelayBatch 测速,它会更新 store.proxies[name].history, + // 确保 UI 显示的延迟与选优结果一致 + await store.testDelayBatch(nodes) + + // 从更新后的 history 读取最新延迟 + const results = nodes.map(name => ({ + name, + delay: store.proxies[name]?.history?.[0]?.delay ?? 0 + })) + + // 找到有效延迟中最低的 + const valid = results.filter(r => r.delay > 0) + if (!valid.length) { + toast.warning('所有节点均超时,未切换') + return + } + valid.sort((a, b) => a.delay - b.delay) + const best = valid[0] + + // 如果当前节点不是最优,则切换 + const currentNow = store.proxies[groupName]?.now ?? '' + if (currentNow !== best.name) { + await store.selectProxy(groupName, best.name) + toast.success('已自动切换到最优节点', { + description: `${best.name} (${best.delay}ms)` + }) + } else { + toast.success('当前节点已是最优', { + description: `${best.name} (${best.delay}ms)` + }) + } + } catch (e) { + logger.error('自动切换失败: ' + e) + } +} + +// ===== 内核更新 ===== +const handleCheckUpdate = async () => { + checkingUpdate.value = true + try { + const info = await store.checkKernelUpdate() + kernelUpdateInfo.value = { latestVersion: info.latestVersion, hasUpdate: info.hasUpdate } + if (info.hasUpdate) { + toast.info('发现新版本', { description: `最新: ${info.latestVersion}` }) + } else { + toast.success('已是最新版本', { description: info.latestVersion }) + } + } catch (e) { + toast.error('检查更新失败', { description: String(e) }) + } finally { + checkingUpdate.value = false + } +} + +const handleUpdateKernel = async () => { + const ok = await showConfirm({ + title: '更新内核', + description: '更新内核需要先停止 mihomo,确认继续?', + confirmText: '继续更新' + }) + if (!ok) return + updatingKernel.value = true + try { + // 先停止 + if (running.value) { + await store.stop() + } + toast.info('正在下载并安装内核...') + await store.updateKernel() + toast.success('内核更新完成') + kernelUpdateInfo.value = null + } catch (e) { + toast.error('内核更新失败', { description: String(e) }) + } finally { + updatingKernel.value = false + } +} // ===== 节点 ===== +const refreshProxies = async () => { + await loadProxiesWithError() + toast.success('节点列表已刷新') +} + const selectNode = async (group: string, name: string) => { - // 仅 Selector 允许手动选择 if (store.proxies[group]?.type !== 'Selector') return try { await store.selectProxy(group, name) + toast.success('节点已切换', { description: name }) } catch (e) { toast.error('切换节点失败', { description: String(e) }) } @@ -186,6 +593,7 @@ const testGroup = async (groupName: string) => { const group = store.proxies[groupName] if (!group?.all?.length) return testingGroups.value.add(groupName) + toast.info(`正在测试「${groupName}」延迟...`) try { await store.testDelayBatch(group.all) toast.success(`「${groupName}」测速完成`) @@ -200,7 +608,44 @@ const nodeDelay = (name: string): number | undefined => { return store.proxies[name]?.history?.[0]?.delay } +/** 自动测试所有代理组的延迟(不弹 toast,静默执行) */ +const autoTestAllGroups = async () => { + if (!groups.value.length) return + // 收集所有组的有效节点(过滤伪节点) + const allNodes = new Set() + for (const [, group] of groups.value) { + for (const node of group.all ?? []) { + if (!isPseudoNode(node)) allNodes.add(node) + } + } + if (!allNodes.size) return + try { + await store.testDelayBatch(Array.from(allNodes)) + } catch { + // 静默失败 + } +} + // ===== 订阅 ===== +// 订阅变更后重载 mihomo 配置(重新生成 config.yaml 并重启进程) +const reloadAfterProfileChange = async (label: string) => { + if (!running.value) return + try { + await store.restart() + // 等待 mihomo API 就绪(mihomo 启动后需要时间初始化配置和 geo 文件) + const ok = await store.waitForApi() + if (!ok) { + toast.error('mihomo 启动超时,API 无响应') + return + } + await store.refreshVersion() + await loadProxiesWithError() + toast.success(label) + } catch (e) { + toast.error('重载配置失败', { description: String(e) }) + } +} + const doImport = async () => { if (!importUrl.value.trim()) { toast.warning('请输入订阅地址') @@ -210,9 +655,9 @@ const doImport = async () => { try { const name = importName.value.trim() || `订阅 ${new Date().toLocaleString()}` await store.importProfile(importUrl.value.trim(), name) - toast.success('订阅导入成功') importUrl.value = '' importName.value = '' + await reloadAfterProfileChange('订阅导入成功,已重载配置') } catch (e) { toast.error('导入失败', { description: String(e) }) } finally { @@ -221,19 +666,26 @@ const doImport = async () => { } const doUpdate = async (id: string) => { + toast.info('正在更新订阅...') try { await store.updateProfile(id) - toast.success('订阅已更新') + await reloadAfterProfileChange('订阅已更新,已重载配置') } catch (e) { toast.error('更新失败', { description: String(e) }) } } const doDelete = async (id: string, name: string) => { - if (!confirm(`确定删除订阅「${name}」?`)) return + const ok = await showConfirm({ + title: '删除订阅', + description: `确定删除订阅「${name}」?此操作不可撤销。`, + confirmText: '删除', + destructive: true + }) + if (!ok) return try { await store.deleteProfile(id) - toast.success('已删除订阅') + await reloadAfterProfileChange('已删除订阅,已重载配置') } catch (e) { toast.error('删除失败', { description: String(e) }) } @@ -256,9 +708,11 @@ const localSettings = ref({ mixedPort: 7890, externalController: '127.0.0.1:9090', secret: '', + mode: 'rule', logLevel: 'info', allowLan: false, - autoStart: false + autoStart: false, + autoSystemProxy: false }) const syncLocalSettings = () => { @@ -267,9 +721,11 @@ const syncLocalSettings = () => { mixedPort: store.settings.mixedPort, externalController: store.settings.externalController, secret: store.settings.secret, + mode: store.settings.mode, logLevel: store.settings.logLevel, allowLan: store.settings.allowLan, - autoStart: store.settings.autoStart + autoStart: store.settings.autoStart, + autoSystemProxy: store.settings.autoSystemProxy } } } @@ -297,7 +753,7 @@ const saveSettingsForm = async () => { + + diff --git a/src/stores/proxyStore.ts b/src/stores/proxyStore.ts index 8e48d4c..3d9974a 100644 --- a/src/stores/proxyStore.ts +++ b/src/stores/proxyStore.ts @@ -16,8 +16,13 @@ export interface ProxySettings { allowLan: boolean systemProxy: boolean autoStart: boolean + autoSystemProxy: boolean currentProfile: string | null profiles: ProfileMeta[] + autoSwitchEnabled: boolean + autoSwitchInterval: number + autoSwitchGroup: string + autoSwitchRegion: string } export interface ProfileMeta { @@ -35,6 +40,13 @@ export interface KernelInfo { version: string | null } +export interface KernelUpdateInfo { + currentVersion: string | null + latestVersion: string + downloadUrl: string + hasUpdate: boolean +} + export interface ProxyStatus { running: boolean pid: number | null @@ -108,6 +120,20 @@ export const useProxyStore = defineStore('proxy', () => { await refreshStatus() } + /** 等待 mihomo API 就绪(轮询 version 接口,最多等 10 秒) */ + const waitForApi = async (timeoutMs = 10000): Promise => { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + await invoke('proxy_version') + return true + } catch { + await new Promise(r => setTimeout(r, 500)) + } + } + return false + } + /** 获取 mihomo 版本(仅运行时可用) */ const refreshVersion = async () => { try { @@ -220,6 +246,16 @@ export const useProxyStore = defineStore('proxy', () => { } } + // ---------- 内核更新 ---------- + const checkKernelUpdate = async (): Promise => { + return await invoke('proxy_check_kernel_update') + } + + const updateKernel = async () => { + await invoke('proxy_update_kernel') + await refreshKernel() + } + return { // state kernel, @@ -234,6 +270,7 @@ export const useProxyStore = defineStore('proxy', () => { start, stop, restart, + waitForApi, refreshVersion, // proxies loadProxies, @@ -251,6 +288,9 @@ export const useProxyStore = defineStore('proxy', () => { // system proxy setSystemProxy, clearSystemProxy, - toggleSystemProxy + toggleSystemProxy, + // kernel update + checkKernelUpdate, + updateKernel } }) diff --git a/src/style.css b/src/style.css index 1e959bd..318041f 100644 --- a/src/style.css +++ b/src/style.css @@ -1,3 +1,6 @@ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + @import "tailwindcss"; @import "tw-animate-css"; @@ -164,4 +167,9 @@ overflow: hidden; background: transparent; @apply antialiased; +} + +/* 确保 Sonner toast 始终在最上层 */ +[data-sonner-toaster] { + z-index: 99999 !important; } \ No newline at end of file