diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a02e696..e83ccf7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -107,6 +107,41 @@ const mcpStatus = ref<{ codexSynced: boolean; } | null>(null); +// 设置页分类导航与状态 +type SettingsTabKey = 'general' | 'security' | 'mcp' | 'about'; +const activeSettingsTab = ref('general'); + +const autostartEnabled = ref(false); +const closeAction = ref<'tray' | 'exit'>('tray'); +const savingAutostart = ref(false); + +const appAbout = ref<{ + version: string; + description: string; + exePath: string; + port: number; + githubUrl: string; + stack: string; +} | null>(null); + +const updateStatus = ref<{ + checking: boolean; + checked: boolean; + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + publishedAt?: string; + releaseNotes?: string; + releaseUrl?: string; + message?: string; +}>({ + checking: false, + checked: false, + hasUpdate: false, + currentVersion: 'v0.1.0', + latestVersion: 'v0.1.0', +}); + // 拖拽排序 const draggingId = ref(null); const dragOverId = ref(null); @@ -320,9 +355,101 @@ async function loadSettings() { if (data.cliSync) { mcpStatus.value = data.cliSync; } + if (data.autostart !== undefined) { + autostartEnabled.value = Boolean(data.autostart); + } + if (data.closeAction) { + closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray'; + } } catch { // ignore } + void loadAutostartSettings(); + void loadAboutInfo(); +} + +async function loadAutostartSettings() { + try { + const res = await fetch('/api/system/autostart'); + const data = await res.json(); + if (data.ok) { + autostartEnabled.value = Boolean(data.autostart); + closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray'; + } + } catch { + // ignore + } +} + +async function saveAutostartSettings() { + savingAutostart.value = true; + try { + const res = await fetch('/api/system/autostart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + autostart: autostartEnabled.value, + closeAction: closeAction.value, + }), + }); + const data = await res.json(); + if (!data.ok) { + showToast(data.error || '保存自启与运行设置失败', 'err'); + return; + } + autostartEnabled.value = Boolean(data.autostart); + closeAction.value = data.closeAction === 'exit' ? 'exit' : 'tray'; + showToast('开机自启与后台常驻设置已生效'); + } catch (e) { + showToast(`保存失败: ${(e as Error).message}`, 'err'); + } finally { + savingAutostart.value = false; + } +} + +async function loadAboutInfo() { + try { + const res = await fetch('/api/system/about'); + const data = await res.json(); + if (data.ok) { + appAbout.value = data; + updateStatus.value.currentVersion = data.version; + } + } catch { + // ignore + } +} + +async function checkAppUpdate() { + updateStatus.value.checking = true; + try { + const res = await fetch('/api/system/check-update', { method: 'POST' }); + const data = await res.json(); + if (data.ok) { + updateStatus.value = { + checking: false, + checked: true, + hasUpdate: Boolean(data.hasUpdate), + currentVersion: data.currentVersion || 'v0.1.0', + latestVersion: data.latestVersion || 'v0.1.0', + publishedAt: data.publishedAt, + releaseNotes: data.releaseNotes, + releaseUrl: data.releaseUrl, + message: data.message, + }; + if (data.hasUpdate) { + showToast(`发现新版本 ${data.latestVersion}!`); + } else { + showToast('当前已是最新版本'); + } + } else { + showToast('检查更新失败', 'err'); + } + } catch (e) { + showToast(`检查更新出错: ${(e as Error).message}`, 'err'); + } finally { + updateStatus.value.checking = false; + } } async function saveMcpSettings(regenerateKey = false) { @@ -441,8 +568,11 @@ function copyText(text: string, msg: string) { showToast(msg); } -function openSettingsPage() { - if (activePage.value === 'settings') { +function openSettingsPage(tab?: SettingsTabKey) { + if (tab) { + activeSettingsTab.value = tab; + } + if (activePage.value === 'settings' && !tab) { activePage.value = 'workspace'; } else { activePage.value = 'settings'; @@ -1453,6 +1583,205 @@ onBeforeUnmount(() => { + +
+ + + + + + + +
+ + +
+ +
+
+
+

开机自启与后台常驻

+

+ 配置 Windows 开机静默后台启动与窗口关闭时的托盘常驻行为 +

+
+
+ + +
+
+
Windows 登录时自动启动
+
+ 开机登录后自动在后台启动 VPS Switch 并静默常驻系统托盘,无需手动双击运行 +
+
+ +
+ + +
+
+
点击窗口右上角关闭按钮 (✕) 时
+
+ 推荐最小化到系统托盘,保持 SSH 连接池与 MCP Server 在后台随时可用 +
+
+
+ + +
+
+
+ + +
+
+
+
界面昼夜主题
+
日间白色毛玻璃 / 夜间深空黑毛玻璃
+
+
+ + +
+
+ +
+
+
终端画布配色
+
独立切换终端区域的白底或黑底
+
+
+ + +
+
+
+
+ + +
{ >{{ displayedMcpConfigJson }}
+ + +
{
+
- + +
-
-
-
界面昼夜主题
-
日间白色毛玻璃 / 夜间深空黑毛玻璃
+
+
+ +
+
+

VPS Switch

+ + {{ updateStatus.currentVersion }} + +
+

+ 轻量级 VPS 管理工具与 MCP Server · 概念数少于 1Shell · 零模型调用 +

+
-
- - +
+ + +
+
+
+ + + {{ + updateStatus.hasUpdate + ? `发现新版本 ${updateStatus.latestVersion}` + : `当前已是最新版本 (${updateStatus.currentVersion})` + }} + +
+ - ☾ 夜间 - + 前往 GitHub 查看发布说明 → + +
+
+ {{ updateStatus.releaseNotes }}
-
-
-
终端画布配色
-
独立切换终端区域的白底或黑底
+ +
+
+
本地服务监听
+
127.0.0.1:{{ appAbout?.port || 18800 }}
-
+
+
技术选型与底座
+
Rust + russh + alacritty_terminal + Wry/Tao + Vue 3
+
+
+
+
可执行文件绝对路径
+ +
+
+ {{ appAbout?.exePath || 'E:\\1shell4.7\\vps-switch\\target\\release\\vps-switch.exe' }} +
+
+
+
+
GitHub 官方私有仓库
+
https://github.com/weidu12123/vps-switch
+
-
+ diff --git a/src/api.rs b/src/api.rs index b68086a..a768803 100644 --- a/src/api.rs +++ b/src/api.rs @@ -36,6 +36,8 @@ use std::{ sync::{Arc, Mutex}, time::Instant, }; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use tokio::io::AsyncReadExt; use tokio_util::io::StreamReader; use uuid::Uuid; @@ -53,6 +55,9 @@ pub struct AppState { pub fn build_api_router(state: Arc) -> Router { let protected_api = Router::new() + .route("/api/system/about", get(api_get_about)) + .route("/api/system/autostart", get(api_get_autostart).post(api_set_autostart)) + .route("/api/system/check-update", post(api_check_update)) .route("/api/mcp/status", get(api_mcp_status).post(api_mcp_sync)) .route("/api/settings", get(api_get_settings)) .route("/api/settings/auth", put(api_update_auth_settings)) @@ -1141,16 +1146,219 @@ async fn api_auth_logout( .unwrap() } +const CREATE_NO_WINDOW: u32 = 0x08000000; + +fn check_autostart_enabled() -> bool { + #[cfg(windows)] + { + std::process::Command::new("reg") + .args(["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "VPSSwitch"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + #[cfg(not(windows))] + false +} + +fn set_autostart_enabled(enabled: bool) -> anyhow::Result<()> { + #[cfg(windows)] + { + if enabled { + let exe = std::env::current_exe()?; + let exe_str = exe.to_string_lossy(); + let cmd_val = format!("\"{}\" --minimized", exe_str); + let status = std::process::Command::new("reg") + .args([ + "add", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", + "/v", + "VPSSwitch", + "/t", + "REG_SZ", + "/d", + &cmd_val, + "/f", + ]) + .creation_flags(CREATE_NO_WINDOW) + .status()?; + if !status.success() { + return Err(anyhow::anyhow!("写入 Windows 注册表开机自启失败")); + } + } else { + let _ = std::process::Command::new("reg") + .args([ + "delete", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", + "/v", + "VPSSwitch", + "/f", + ]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + } + Ok(()) + } + #[cfg(not(windows))] + Ok(()) +} + +async fn api_get_autostart(State(state): State>) -> impl IntoResponse { + let enabled = check_autostart_enabled(); + let close_action = state + .db + .get_setting("close_action") + .ok() + .flatten() + .unwrap_or_else(|| "tray".to_string()); + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "autostart": enabled, + "closeAction": close_action, + })), + ) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetAutostartPayload { + autostart: Option, + close_action: Option, +} + +async fn api_set_autostart( + State(state): State>, + Json(p): Json, +) -> impl IntoResponse { + if let Some(enable) = p.autostart { + if let Err(e) = set_autostart_enabled(enable) { + return err_response(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); + } + } + if let Some(ref action) = p.close_action { + let valid_action = if action == "exit" { "exit" } else { "tray" }; + let _ = state.db.set_setting("close_action", valid_action); + } + let enabled = check_autostart_enabled(); + let close_action = state + .db + .get_setting("close_action") + .ok() + .flatten() + .unwrap_or_else(|| "tray".to_string()); + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "autostart": enabled, + "closeAction": close_action, + })), + ) + .into_response() +} + +async fn api_get_about(State(state): State>) -> impl IntoResponse { + let exe_path = std::env::current_exe() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "version": format!("v{}", env!("CARGO_PKG_VERSION")), + "description": "轻量级 VPS 管理工具与 MCP Server", + "exePath": exe_path, + "port": state.port, + "githubUrl": "https://github.com/weidu12123/vps-switch", + "stack": "Rust (Axum + russh + alacritty_terminal + Wry/Tao) + Vue 3", + })), + ) +} + +async fn api_check_update() -> impl IntoResponse { + let current_version = env!("CARGO_PKG_VERSION"); + + #[cfg(windows)] + let gh_res = std::process::Command::new("gh") + .args([ + "release", + "view", + "--repo", + "weidu12123/vps-switch", + "--json", + "tagName,name,publishedAt,body,url", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + + #[cfg(not(windows))] + let gh_res = std::process::Command::new("gh") + .args([ + "release", + "view", + "--repo", + "weidu12123/vps-switch", + "--json", + "tagName,name,publishedAt,body,url", + ]) + .output(); + + if let Ok(out) = gh_res { + if out.status.success() { + if let Ok(info) = serde_json::from_slice::(&out.stdout) { + let tag = info["tagName"].as_str().unwrap_or("").trim_start_matches('v'); + let has_update = !tag.is_empty() && tag != current_version; + return ( + StatusCode::OK, + Json(json!({ + "ok": true, + "currentVersion": format!("v{current_version}"), + "latestVersion": info["tagName"].as_str().unwrap_or(&format!("v{current_version}")), + "hasUpdate": has_update, + "publishedAt": info["publishedAt"].as_str(), + "releaseNotes": info["body"].as_str(), + "releaseUrl": info["url"].as_str(), + })), + ); + } + } + } + + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "currentVersion": format!("v{current_version}"), + "latestVersion": format!("v{current_version}"), + "hasUpdate": false, + "message": "当前已是最新版本", + "releaseUrl": "https://github.com/weidu12123/vps-switch", + })), + ) +} + async fn api_get_settings(State(state): State>) -> impl IntoResponse { match state.db.get_app_settings() { Ok(settings) => { let cli_sync = sync_cli_mcp_configs(state.port); + let autostart = check_autostart_enabled(); + let close_action = state + .db + .get_setting("close_action") + .ok() + .flatten() + .unwrap_or_else(|| "tray".to_string()); ( StatusCode::OK, Json(json!({ "ok": true, "settings": settings, "cliSync": cli_sync, + "autostart": autostart, + "closeAction": close_action, })), ) .into_response() diff --git a/src/desktop.rs b/src/desktop.rs index 8001ae4..8afea75 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -179,11 +179,14 @@ pub fn run_desktop_window( let win_icon = WindowIcon::from_rgba(rgba64.clone(), 64, 64).ok(); let tray_icon_img = TrayIconImage::from_rgba(rgba64, 64, 64)?; + let start_minimized = std::env::args().any(|a| a == "--minimized" || a == "--hidden"); + let window = WindowBuilder::new() .with_title("VPS Switch") .with_inner_size(Size::Logical(LogicalSize::new(1280.0, 800.0))) .with_min_inner_size(Size::Logical(LogicalSize::new(920.0, 600.0))) .with_window_icon(win_icon.clone()) + .with_visible(!start_minimized) .build(&event_loop)?; let url = format!("http://127.0.0.1:{port}"); @@ -227,8 +230,21 @@ pub fn run_desktop_window( .. } => { if window_id == window.id() { - // 点击主窗口右上角 × 最小化驻留到系统托盘,保持后台不断连 - window.set_visible(false); + let close_action = state + .db + .get_setting("close_action") + .ok() + .flatten() + .unwrap_or_else(|| "tray".to_string()); + if close_action == "exit" { + standalone_windows.clear(); + host_to_window.clear(); + let _ = tray_icon.take(); + *control_flow = ControlFlow::Exit; + } else { + // 默认最小化驻留到系统托盘,保持后台不断连 + window.set_visible(false); + } } else if let Some(removed) = standalone_windows.remove(&window_id) { // 独立终端窗口点击 ×:完全关闭并销毁该窗口 host_to_window.remove(&removed.host_id);