diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index 8873c74..bb75f6b 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -15,7 +15,7 @@ use axum::{ }; use tauri::{ async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl, - WebviewWindowBuilder, + WebviewWindow, WebviewWindowBuilder, }; use tauri_plugin_opener::OpenerExt; use tokio_util::sync::CancellationToken; @@ -30,6 +30,7 @@ use crate::frontend; use crate::tray; pub(crate) const MAIN_WINDOW_LABEL: &str = "main"; +const AUTOSTART_ARG: &str = "--autostart"; struct DesktopRuntime { shutdown: CancellationToken, @@ -52,12 +53,30 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> { .spawn()?; Ok(()) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] { - let _ = command; + const TERMINALS: &[(&str, &[&str])] = &[ + ("x-terminal-emulator", &["-e"]), + ("gnome-terminal", &["--"]), + ("konsole", &["-e"]), + ("xfce4-terminal", &["--execute"]), + ("alacritty", &["-e"]), + ("kitty", &[]), + ]; + let script = format!("{command}; exec bash"); + for (terminal, separator) in TERMINALS { + let mut process = Command::new(terminal); + process.args(*separator); + process.arg("bash").arg("-c").arg(&script); + match process.spawn() { + Ok(_) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + } + } Err(tauri::Error::from(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "terminal guidance is unsupported on this platform", + std::io::ErrorKind::NotFound, + "no supported terminal emulator found", ))) } } @@ -96,7 +115,10 @@ fn desktop_api_router(app: AppHandle) -> Router { .layer(Extension(app)) } -fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> { +fn create_main_window( + app: &AppHandle, + address: std::net::SocketAddr, +) -> tauri::Result { let url = format!("http://{address}/__byok-api__/") .parse() .expect("local frontend URL"); @@ -108,18 +130,20 @@ fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri:: .background_color(Color(20, 20, 20, 255)) .decorations(cfg!(target_os = "macos")) .shadow(true) - .resizable(true); + .resizable(true) + .visible(false); #[cfg(target_os = "macos")] let builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true); - builder.build()?; - Ok(()) + builder.build() } pub fn run() { + let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG); + tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() @@ -130,17 +154,19 @@ pub fn run() { let app = tauri::Builder::default() .invoke_handler(tauri::generate_handler![open_terminal_with_command]) - .plugin(tauri_plugin_single_instance::init(|app, _, _| { - tray::show_main_window(app); + .plugin(tauri_plugin_single_instance::init(|app, args, _| { + if !args.iter().any(|arg| arg == AUTOSTART_ARG) { + tray::show_main_window(app); + } })) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .setup(|app| { + .setup(move |app| { app.handle().plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, - None, + Some(vec![AUTOSTART_ARG]), ))?; let config = Config::desktop()?; #[cfg(dev)] @@ -160,6 +186,9 @@ pub fn run() { let listener = tauri::async_runtime::block_on(server.bind())?; let address = listener.local_addr()?; tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?; + let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings()) + .map(|settings| settings.silent_start) + .unwrap_or(false); let shutdown = CancellationToken::new(); let server_shutdown = shutdown.clone(); let app_handle = app.handle().clone(); @@ -176,7 +205,13 @@ pub fn run() { server: Mutex::new(Some(task)), exiting: AtomicBool::new(false), }); - create_main_window(app.handle(), address)?; + let window = create_main_window(app.handle(), address)?; + if silent_start && started_by_autostart { + tracing::info!("silent autostart enabled; keeping the main window hidden"); + } else { + window.show()?; + window.set_focus()?; + } tray::create(app)?; Ok(()) }) diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 7f9fec3..0e2d15d 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -66,7 +66,7 @@ export interface ModelConnectivityResult { output: string; } -export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported"; +export type CaState = "missing" | "untrusted" | "ready" | "invalid"; export type IntegrationState = "disabled" | "enabled" | "degraded"; export interface CursorHarnessStatus { platform: string; @@ -114,6 +114,10 @@ export interface TabSettings { address: string; } +export interface DesktopSettings { + silent_start: boolean; +} + export interface OverviewMetrics { llm_calls: number; successful_calls: number; @@ -310,4 +314,6 @@ export const api = { setProxySettings: (settings: ProxySettingsInput) => request("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }), tabSettings: () => request("/settings/tab"), setTabSettings: (settings: TabSettings) => request("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }), + desktopSettings: () => request("/settings/desktop"), + setDesktopSettings: (settings: DesktopSettings) => request("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }), }; diff --git a/apps/desktop/src/components/cursor/CursorGates.tsx b/apps/desktop/src/components/cursor/CursorGates.tsx index d742b9f..f2d038e 100644 --- a/apps/desktop/src/components/cursor/CursorGates.tsx +++ b/apps/desktop/src/components/cursor/CursorGates.tsx @@ -15,12 +15,11 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, const ready = useContext(CaReady); const { cursorHarness } = useAppStore(); if (ready) return children; - const unsupported = cursorHarness?.ca === "unsupported"; const installedLocally = cursorHarness?.ca === "untrusted"; return
- {unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")} - {unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")} - {!unsupported && } + {installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")} + {installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")} +
; } diff --git a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx index e255a23..9160069 100644 --- a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx @@ -3,7 +3,9 @@ import { currentAppVersion, hasNativeAppLifecycle, readAutostart, + readSilentStart, writeAutostart, + writeSilentStart, } from "../../native/appLifecycle"; import { updateStore, useUpdateStore } from "../../store/updateStore"; import { Button } from "../ui/Button"; @@ -19,6 +21,8 @@ export function AppLifecycleSettingsCard() { const [version, setVersion] = useState("…"); const [autostart, setAutostart] = useState(false); const [loadingAutostart, setLoadingAutostart] = useState(native); + const [silentStart, setSilentStart] = useState(false); + const [loadingSilentStart, setLoadingSilentStart] = useState(native); useEffect(() => { let disposed = false; @@ -28,6 +32,10 @@ export function AppLifecycleSettingsCard() { .then((enabled) => { if (!disposed) setAutostart(enabled); }) .catch((cause) => message(cause instanceof Error ? cause.message : String(cause))) .finally(() => { if (!disposed) setLoadingAutostart(false); }); + void readSilentStart() + .then((silent) => { if (!disposed) setSilentStart(silent); }) + .catch(() => {}) + .finally(() => { if (!disposed) setLoadingSilentStart(false); }); } return () => { disposed = true; }; }, [message, native]); @@ -45,6 +53,19 @@ export function AppLifecycleSettingsCard() { } }; + const toggleSilentStart = async (enabled: boolean) => { + try { + setLoadingSilentStart(true); + await writeSilentStart(enabled); + setSilentStart(await readSilentStart()); + message(enabled ? t("已开启静默启动") : t("已关闭静默启动")); + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoadingSilentStart(false); + } + }; + const checkUpdate = async () => { try { const nextVersion = await updateStore.check(); @@ -75,6 +96,18 @@ export function AppLifecycleSettingsCard() { onChange={(enabled) => void toggleAutostart(enabled)} /> + {autostart &&
+
+ {t("静默启动")} + {t("开机启动时不显示主窗口,仅保留系统托盘图标。")} +
+ void toggleSilentStart(enabled)} + /> +
}
{t("软件更新")} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 32969c8..3125c20 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -431,8 +431,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 22, - "column": 107 + "line": 21, + "column": 65 } ] }, @@ -484,7 +484,7 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 38, + "line": 37, "column": 59 }, { @@ -576,7 +576,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 87, + "line": 120, "column": 27 } ] @@ -789,7 +789,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 51, + "line": 72, "column": 29 } ] @@ -803,7 +803,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 83, + "line": 116, "column": 13 } ] @@ -1320,6 +1320,18 @@ } ] }, + "47d1c20aa017ff05": { + "source": "开机启动时不显示主窗口,仅保留系统托盘图标。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 102, + "column": 17 + } + ] + }, "48b970b568a7f8f9": { "source": "代理设置", "kind": "text", @@ -1355,7 +1367,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 82, + "line": 115, "column": 13 } ] @@ -1514,8 +1526,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 23, - "column": 222 + "line": 22, + "column": 205 } ] }, @@ -1665,7 +1677,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 40, + "line": 48, "column": 40 } ] @@ -1789,7 +1801,7 @@ "refs": [ { "file": "api.ts", - "line": 290, + "line": 294, "column": 43 } ] @@ -1801,7 +1813,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 80, + "line": 113, "column": 18 } ] @@ -1844,7 +1856,7 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 36, + "line": 35, "column": 14 } ] @@ -1974,7 +1986,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 51, + "line": 72, "column": 78 } ] @@ -2015,18 +2027,6 @@ } ] }, - "722e156c20650a3f": { - "source": "请使用 macOS 或 Windows。", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/cursor/CursorGates.tsx", - "line": 22, - "column": 26 - } - ] - }, "7392e20d61abaa07": { "source": "额外保存完整请求和流响应;默认只保存时间、状态与用量。", "kind": "text", @@ -2082,7 +2082,7 @@ "refs": [ { "file": "api.ts", - "line": 285, + "line": 289, "column": 43 } ] @@ -2185,8 +2185,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 23, - "column": 138 + "line": 22, + "column": 121 } ] }, @@ -2197,7 +2197,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 91, + "line": 124, "column": 37 } ] @@ -2221,7 +2221,7 @@ "refs": [ { "file": "api.ts", - "line": 231, + "line": 235, "column": 21 } ] @@ -2250,12 +2250,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 68, + "line": 89, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 74, + "line": 95, "column": 16 } ] @@ -2272,6 +2272,18 @@ } ] }, + "864597982c308d72": { + "source": "已开启静默启动", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 61, + "column": 25 + } + ] + }, "86b7355ec3bd55ef": { "source": "隐藏 API Key", "kind": "text", @@ -2508,18 +2520,6 @@ } ] }, - "95f76d30c25d5eda": { - "source": "当前系统暂不支持安装 CA", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/cursor/CursorGates.tsx", - "line": 21, - "column": 28 - } - ] - }, "966498853d801a52": { "source": "TAB 选择", "kind": "text", @@ -2558,8 +2558,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 23, - "column": 205 + "line": 22, + "column": 188 } ] }, @@ -2616,6 +2616,18 @@ } ] }, + "9e356080c56877f8": { + "source": "已关闭静默启动", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 61, + "column": 40 + } + ] + }, "9e46da6923836182": { "source": "如:2026-08-23 09:00、1小时前", "kind": "text", @@ -2683,11 +2695,28 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 65, + "line": 86, "column": 29 } ] }, + "a1b8c98f29374a2f": { + "source": "静默启动", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 101, + "column": 18 + }, + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 107, + "column": 16 + } + ] + }, "a2901cd2743c1921": { "source": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。", "kind": "text", @@ -2734,8 +2763,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 23, - "column": 170 + "line": 22, + "column": 153 } ] }, @@ -2906,7 +2935,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 40, + "line": 48, "column": 25 } ] @@ -2918,7 +2947,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 87, + "line": 120, "column": 39 } ] @@ -3561,7 +3590,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 69, + "line": 90, "column": 17 } ] @@ -3962,8 +3991,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 21, - "column": 68 + "line": 20, + "column": 33 } ] }, @@ -4121,7 +4150,7 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 37, + "line": 36, "column": 12 } ] @@ -4296,7 +4325,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 91, + "line": 124, "column": 25 } ] @@ -4308,8 +4337,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 21, - "column": 89 + "line": 20, + "column": 54 } ] }, @@ -4422,8 +4451,8 @@ "refs": [ { "file": "components/cursor/CursorGates.tsx", - "line": 22, - "column": 73 + "line": 21, + "column": 31 } ] }, diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 2f6f72b..c8d105d 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -88,6 +88,7 @@ "43cb41d62de2d179": "Proxy requires authentication", "461d6a57900c2ed7": "Connectivity test failed: {error}", "470049252e54de6a": "Success rate: {rate}", + "47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.", "48b970b568a7f8f9": "Proxy settings", "48d8db17bae06246": "{count} total", "492042ed1fdc29ed": "Version {version} is ready to install", @@ -137,7 +138,6 @@ "6e86570183c3cdd0": "You're up to date", "7005693f4f050bce": "Cache I/O {cost}", "71eb2c6fce991a29": "Add provider", - "722e156c20650a3f": "Use macOS or Windows.", "7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.", "75844712d53a24de": "Values must be strings; when editing, null keeps the original value of the corresponding sensitive Header.", "788db1cfec2a3db5": "Theme", @@ -157,6 +157,7 @@ "83fcfb4c1f2c1641": "Fetch models", "842b9f11cdd96bda": "Launch at login", "84924374710e03bd": "Base URL must be a valid URL", + "864597982c308d72": "Silent start enabled", "86b7355ec3bd55ef": "Hide API Key", "8716e1344b0daddb": "Cursor official", "878a8ab176429a86": "View instructions", @@ -174,7 +175,6 @@ "946b3ffc02f026c0": "Delete this model?", "94803f35c825e47a": "Full request URL", "94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.", - "95f76d30c25d5eda": "CA installation is not supported on this system", "966498853d801a52": "TAB connection", "9850ed41a5bfbb0c": "{count} selected", "997ec8201c2adeda": "Open terminal to install CA", @@ -182,12 +182,14 @@ "9ac0ac940982895d": "Select provider", "9c41b3a9e12ac994": "Reasoning effort", "9d8f2ef4e85ea665": "Custom context tokens", + "9e356080c56877f8": "Silent start disabled", "9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago", "9f6fee1aba17a565": "Language", "9fb48101d237ff96": "Last week", "a026f37e613cf48b": "Output Tokens", "a03a1a0cb35414f8": " must be an integer from 0 to 65535", "a1a42cd9b16e2162": "Application", + "a1b8c98f29374a2f": "Silent start", "a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.", "a3030bf8f16dc63c": "Save", "a363743025795ec7": "I've initialized it — refresh", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 589fa4b..7dea200 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -88,6 +88,7 @@ "43cb41d62de2d179": "代理需要认证", "461d6a57900c2ed7": "连通性测试失败:{error}", "470049252e54de6a": "成功占比:{rate}", + "47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。", "48b970b568a7f8f9": "代理设置", "48d8db17bae06246": "共 {count} 条", "492042ed1fdc29ed": "版本 {version} 可以安装", @@ -137,7 +138,6 @@ "6e86570183c3cdd0": "当前已是最新版本", "7005693f4f050bce": "缓存读写 {cost}", "71eb2c6fce991a29": "添加上游", - "722e156c20650a3f": "请使用 macOS 或 Windows。", "7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。", "75844712d53a24de": "值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。", "788db1cfec2a3db5": "主题", @@ -157,6 +157,7 @@ "83fcfb4c1f2c1641": "获取模型", "842b9f11cdd96bda": "开机启动", "84924374710e03bd": "Base URL 必须是有效地址", + "864597982c308d72": "已开启静默启动", "86b7355ec3bd55ef": "隐藏 API Key", "8716e1344b0daddb": "Cursor 官方", "878a8ab176429a86": "查看说明", @@ -174,7 +175,6 @@ "946b3ffc02f026c0": "确定删除这个模型吗?", "94803f35c825e47a": "请求完整地址", "94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。", - "95f76d30c25d5eda": "当前系统暂不支持安装 CA", "966498853d801a52": "TAB 选择", "9850ed41a5bfbb0c": "已选 {count} 项", "997ec8201c2adeda": "打开终端安装 CA", @@ -182,12 +182,14 @@ "9ac0ac940982895d": "选择上游", "9c41b3a9e12ac994": "思考强度", "9d8f2ef4e85ea665": "自定义上下文 tokens", + "9e356080c56877f8": "已关闭静默启动", "9e46da6923836182": "如:2026-08-23 09:00、1小时前", "9f6fee1aba17a565": "语言", "9fb48101d237ff96": "近一周", "a026f37e613cf48b": "输出 Token", "a03a1a0cb35414f8": "必须是 0–65535 之间的整数", "a1a42cd9b16e2162": "应用设置", + "a1b8c98f29374a2f": "静默启动", "a2901cd2743c1921": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。", "a3030bf8f16dc63c": "保存", "a363743025795ec7": "我已初始化,刷新", diff --git a/apps/desktop/src/native/appLifecycle.ts b/apps/desktop/src/native/appLifecycle.ts index a3b9de8..8c3f12e 100644 --- a/apps/desktop/src/native/appLifecycle.ts +++ b/apps/desktop/src/native/appLifecycle.ts @@ -3,6 +3,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; +import { api } from "../api"; export function hasNativeAppLifecycle(): boolean { return isTauri(); @@ -20,6 +21,14 @@ export async function writeAutostart(enabled: boolean): Promise { await (enabled ? enable() : disable()); } +export async function readSilentStart(): Promise { + return (await api.desktopSettings()).silent_start; +} + +export async function writeSilentStart(silentStart: boolean): Promise { + await api.setDesktopSettings({ silent_start: silentStart }); +} + export async function checkForUpdate(): Promise { return check(); } diff --git a/apps/desktop/src/pages/CursorSettingsPage.tsx b/apps/desktop/src/pages/CursorSettingsPage.tsx index c0e0d0f..f232042 100644 --- a/apps/desktop/src/pages/CursorSettingsPage.tsx +++ b/apps/desktop/src/pages/CursorSettingsPage.tsx @@ -170,7 +170,11 @@ export function CursorSettingsPage() { } }; const openCaTerminal = () => { - if (caCommand) void api.openCursorCaInstallTerminal(caCommand); + if (caCommand) { + api + .openCursorCaInstallTerminal(caCommand) + .catch((cause) => message(cause instanceof Error ? cause.message : String(cause))); + } setCaCommand(null); setWaitingForCaRefresh(true); }; diff --git a/server/src/app.rs b/server/src/app.rs index b94af4e..5d4749f 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -84,6 +84,10 @@ impl App { self.harness.clone() } + pub fn store(&self) -> Store { + self.store.clone() + } + pub async fn serve(self) -> Result<()> { let listener = self.bind().await?; let shutdown = CancellationToken::new(); diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index 7a3d9f8..ca2a0e6 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -164,6 +164,10 @@ pub fn api_router(service: ControlService) -> Router { "/__byok-api__/api/settings/tab", get(settings::get_tab).put(settings::update_tab), ) + .route( + "/__byok-api__/api/settings/desktop", + get(settings::get_desktop).put(settings::update_desktop), + ) .route( "/__byok-api__/api/harness/cursor/status", get(harness::status), diff --git a/server/src/control/service.rs b/server/src/control/service.rs index e93844a..4f95d7f 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -22,7 +22,8 @@ use crate::{ }, provider::{ModelEvent, Provider}, store::{ - PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings, + DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, + TabSettings, }, Error, Result, }; @@ -497,6 +498,14 @@ impl ControlService { pub async fn set_tab_settings(&self, settings: TabSettings) -> Result { self.cursor_harness.set_tab_settings(settings).await } + + pub async fn desktop_settings(&self) -> Result { + self.store.desktop_settings().await + } + + pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> { + self.store.set_desktop_settings(settings).await + } } fn official_call(trace: CursorRunTraceSummary) -> CallSummary { diff --git a/server/src/control/settings.rs b/server/src/control/settings.rs index bef0ec4..440a7e0 100644 --- a/server/src/control/settings.rs +++ b/server/src/control/settings.rs @@ -2,7 +2,8 @@ use crate::Result; use axum::{extract::State, Json}; use crate::store::{ - PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings, + DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, + TabSettings, }; use super::{ControlService, ObservabilitySettings}; @@ -60,3 +61,15 @@ pub async fn update_tab( ) -> Result> { Ok(Json(service.set_tab_settings(settings).await?)) } + +pub async fn get_desktop(State(service): State) -> Result> { + Ok(Json(service.desktop_settings().await?)) +} + +pub async fn update_desktop( + State(service): State, + Json(settings): Json, +) -> Result> { + service.set_desktop_settings(settings).await?; + get_desktop(State(service)).await +} diff --git a/server/src/cursor/tools/result/exec/output.rs b/server/src/cursor/tools/result/exec/output.rs index b221198..e78e697 100644 --- a/server/src/cursor/tools/result/exec/output.rs +++ b/server/src/cursor/tools/result/exec/output.rs @@ -409,6 +409,10 @@ fn creates_subagent(call: &ToolCall) -> bool { ) } +fn missing(name: &str) -> Error { + Error::Protocol(format!("{name} returned no result")) +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -508,7 +512,3 @@ mod tests { assert!(content.contains("cannot find value `name`")); } } - -fn missing(name: &str) -> Error { - Error::Protocol(format!("{name} returned no result")) -} diff --git a/server/src/harness/ca.rs b/server/src/harness/ca.rs index 0ea2ae1..bce7118 100644 --- a/server/src/harness/ca.rs +++ b/server/src/harness/ca.rs @@ -46,9 +46,6 @@ impl CaManager { } pub fn state(&self) -> Result { - if !matches!(std::env::consts::OS, "macos" | "windows") { - return Ok(CaState::Unsupported); - } let cert = fs::read_to_string(self.cert_path()); let key = fs::read_to_string(self.key_path()); match (cert, key) { @@ -93,18 +90,21 @@ impl CaManager { "certutil -addstore -f Root \"{}\"", self.cert_path().display() )), + "linux" => { + let anchor = linux_anchor_file(); + Some(format!( + "sudo cp '{}' '{}' && sudo {}", + path, + anchor.display(), + linux_refresh_command() + )) + } _ => None, } } pub fn initialize_local(&self) -> Result<()> { match self.state()? { - CaState::Unsupported => { - return Err(Error::Config(format!( - "CA installation is not supported on {}", - std::env::consts::OS - ))) - } CaState::Invalid => { return Err(Error::Config("CA files are incomplete or invalid".into())) } @@ -210,8 +210,31 @@ fn is_installed(cert: &str) -> Result { } #[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn is_installed(_cert: &str) -> Result { - Ok(false) +fn is_installed(cert: &str) -> Result { + match fs::read_to_string(linux_anchor_file()) { + Ok(installed) => Ok(installed.trim() == cert.trim()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +const LINUX_ANCHOR_NAME: &str = "cursor-byok-local-ca.crt"; + +fn linux_anchor_file() -> PathBuf { + if PathBuf::from("/etc/pki/ca-trust/source/anchors").is_dir() { + PathBuf::from("/etc/pki/ca-trust/source/anchors").join(LINUX_ANCHOR_NAME) + } else if PathBuf::from("/etc/ca-certificates/trust-source/anchors").is_dir() { + PathBuf::from("/etc/ca-certificates/trust-source/anchors").join(LINUX_ANCHOR_NAME) + } else { + PathBuf::from("/usr/local/share/ca-certificates").join(LINUX_ANCHOR_NAME) + } +} + +fn linux_refresh_command() -> &'static str { + match linux_anchor_file().parent().and_then(|dir| dir.to_str()) { + Some("/usr/local/share/ca-certificates") => "update-ca-certificates", + _ => "update-ca-trust extract", + } } #[cfg(test)] diff --git a/server/src/harness/mod.rs b/server/src/harness/mod.rs index fab6baa..f618ab4 100644 --- a/server/src/harness/mod.rs +++ b/server/src/harness/mod.rs @@ -31,7 +31,6 @@ pub enum CaState { Untrusted, Ready, Invalid, - Unsupported, } #[derive(Clone, Debug, Serialize)] diff --git a/server/src/store/settings.rs b/server/src/store/settings.rs index d161cb4..ca8fbce 100644 --- a/server/src/store/settings.rs +++ b/server/src/store/settings.rs @@ -8,6 +8,7 @@ const PORT_SETTINGS_KEY: &str = "network_ports"; const PROXY_SETTINGS_KEY: &str = "outbound_proxy"; const TAB_SETTINGS_KEY: &str = "cursor_tab"; const INSTALLATION_ID_KEY: &str = "installation_id"; +const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle"; pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn"; @@ -46,6 +47,12 @@ pub struct TabSettings { pub address: String, } +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct DesktopSettings { + #[serde(default)] + pub silent_start: bool, +} + impl TabSettings { pub fn service_url(&self) -> Option<&str> { match self.mode { @@ -243,6 +250,30 @@ impl Store { settings.proxy_port = port; self.set_port_settings(settings).await } + + pub async fn desktop_settings(&self) -> Result { + let value = sqlx::query_scalar::<_, String>( + "SELECT value_json FROM service_settings WHERE setting_key = ?", + ) + .bind(DESKTOP_SETTINGS_KEY) + .fetch_optional(&self.pool) + .await?; + value + .map(|value| serde_json::from_str(&value).map_err(Into::into)) + .unwrap_or_else(|| Ok(DesktopSettings::default())) + } + + pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> { + sqlx::query( + "INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms", + ) + .bind(DESKTOP_SETTINGS_KEY) + .bind(serde_json::to_string(&settings)?) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } } #[cfg(test)]