diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 07b264a..1954d26 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-is-maximized", "core:window:allow-close", + "core:app:allow-set-dock-visibility", "allow-open-terminal-with-command", "clipboard-manager:allow-write-text", "autostart:default", diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index bb75f6b..e4b767b 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -186,9 +186,12 @@ 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 desktop_settings = + tauri::async_runtime::block_on(server.store().desktop_settings()) + .unwrap_or_default(); + #[cfg(target_os = "macos")] + app.handle() + .set_dock_visibility(desktop_settings.show_dock_icon)?; let shutdown = CancellationToken::new(); let server_shutdown = shutdown.clone(); let app_handle = app.handle().clone(); @@ -206,7 +209,7 @@ pub fn run() { exiting: AtomicBool::new(false), }); let window = create_main_window(app.handle(), address)?; - if silent_start && started_by_autostart { + if desktop_settings.silent_start && started_by_autostart { tracing::info!("silent autostart enabled; keeping the main window hidden"); } else { window.show()?; diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 9eabcad..7ebaf90 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -143,6 +143,7 @@ export interface TabSettings { export interface DesktopSettings { silent_start: boolean; + show_dock_icon: boolean; } export interface OverviewMetrics { diff --git a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx index 9160069..ef8c5c1 100644 --- a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from "react"; import { currentAppVersion, + hasDockVisibilitySetting, hasNativeAppLifecycle, readAutostart, - readSilentStart, + readDesktopSettings, writeAutostart, + writeDockIconVisibility, writeSilentStart, } from "../../native/appLifecycle"; import { updateStore, useUpdateStore } from "../../store/updateStore"; @@ -17,12 +19,14 @@ import styles from "./AppLifecycleSettingsCard.module.scss"; export function AppLifecycleSettingsCard() { const message = useMessage(); const native = hasNativeAppLifecycle(); + const dockVisibilitySetting = hasDockVisibilitySetting(); const { availableVersion, checking, installing } = useUpdateStore(); const [version, setVersion] = useState("…"); const [autostart, setAutostart] = useState(false); const [loadingAutostart, setLoadingAutostart] = useState(native); const [silentStart, setSilentStart] = useState(false); - const [loadingSilentStart, setLoadingSilentStart] = useState(native); + const [dockIconVisible, setDockIconVisible] = useState(true); + const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native); useEffect(() => { let disposed = false; @@ -32,10 +36,17 @@ 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); }) + void readDesktopSettings() + .then((settings) => { + if (disposed) return; + setSilentStart(settings.silent_start); + setDockIconVisible(settings.show_dock_icon); + }) .catch(() => {}) - .finally(() => { if (!disposed) setLoadingSilentStart(false); }); + .finally(() => { + if (disposed) return; + setLoadingDesktopSettings(false); + }); } return () => { disposed = true; }; }, [message, native]); @@ -55,14 +66,27 @@ export function AppLifecycleSettingsCard() { const toggleSilentStart = async (enabled: boolean) => { try { - setLoadingSilentStart(true); + setLoadingDesktopSettings(true); await writeSilentStart(enabled); - setSilentStart(await readSilentStart()); + setSilentStart(enabled); message(enabled ? t("已开启静默启动") : t("已关闭静默启动")); } catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); } finally { - setLoadingSilentStart(false); + setLoadingDesktopSettings(false); + } + }; + + const toggleDockIcon = async (visible: boolean) => { + try { + setLoadingDesktopSettings(true); + await writeDockIconVisibility(visible); + setDockIconVisible(visible); + message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标")); + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoadingDesktopSettings(false); } }; @@ -103,11 +127,23 @@ export function AppLifecycleSettingsCard() { void toggleSilentStart(enabled)} /> } + {dockVisibilitySetting &&
+
+ {t("在 Dock 栏显示")} + {t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")} +
+ void toggleDockIcon(visible)} + /> +
}
{t("软件更新")} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 6c6d5ea..a278fc2 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -516,7 +516,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 27 } ] @@ -735,7 +735,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 29 } ] @@ -773,7 +773,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 116, + "line": 152, "column": 13 } ] @@ -1095,18 +1095,6 @@ } ] }, - "393df9bb13ea4900": { - "source": "命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 14 - } - ] - }, "393e1241552b1870": { "source": "请求", "kind": "text", @@ -1196,6 +1184,18 @@ } ] }, + "3b67824289b5fa1e": { + "source": "已隐藏 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 45 + } + ] + }, "3c94b4c75940c178": { "source": "输入 Token", "kind": "text", @@ -1392,7 +1392,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 102, + "line": 126, "column": 17 } ] @@ -1432,7 +1432,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 115, + "line": 151, "column": 13 } ] @@ -1868,7 +1868,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 40 } ] @@ -2004,7 +2004,7 @@ "refs": [ { "file": "api.ts", - "line": 314, + "line": 315, "column": 43 } ] @@ -2016,7 +2016,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 113, + "line": 149, "column": 18 } ] @@ -2095,6 +2095,23 @@ } ] }, + "680680288a6d2ad2": { + "source": "在 Dock 栏显示", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 137, + "column": 18 + }, + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 143, + "column": 16 + } + ] + }, "68ad603fafe4e0d6": { "source": "导入旧版配置", "kind": "text", @@ -2189,7 +2206,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 78 } ] @@ -2280,7 +2297,7 @@ "refs": [ { "file": "api.ts", - "line": 309, + "line": 310, "column": 43 } ] @@ -2395,7 +2412,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 37 } ] @@ -2407,7 +2424,7 @@ "refs": [ { "file": "api.ts", - "line": 258, + "line": 259, "column": 21 } ] @@ -2446,12 +2463,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 89, + "line": 113, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 95, + "line": 119, "column": 16 } ] @@ -2475,7 +2492,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 25 } ] @@ -2810,7 +2827,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 40 } ] @@ -2896,7 +2913,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 86, + "line": 110, "column": 29 } ] @@ -2908,12 +2925,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 101, + "line": 125, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 107, + "line": 131, "column": 16 } ] @@ -3142,7 +3159,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 25 } ] @@ -3166,7 +3183,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 39 } ] @@ -3199,6 +3216,18 @@ } ] }, + "aecb952b1e6cce36": { + "source": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 138, + "column": 17 + } + ] + }, "aee88743413144a2": { "source": "刷新", "kind": "text", @@ -3308,6 +3337,18 @@ } ] }, + "b6725f218ebaef26": { + "source": "已显示 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 25 + } + ] + }, "b75a46aad3e7c132": { "source": "非缓存输入:{tokens}", "kind": "template", @@ -3493,7 +3534,7 @@ "refs": [ { "file": "components/metrics/CacheHitRateChart.tsx", - "line": 71, + "line": 58, "column": 62 } ] @@ -3724,7 +3765,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 90, + "line": 114, "column": 17 } ] @@ -4323,18 +4364,6 @@ } ] }, - "f3a76d896853c1df": { - "source": "未命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 23 - } - ] - }, "f4694c46b1e19602": { "source": "最终请求类型", "kind": "text", @@ -4493,7 +4522,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 317ebfa..af4c6f1 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required", "378bb0eec39fa8a2": "Last page", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", - "393df9bb13ea4900": "Hit", "393e1241552b1870": "Request", "398f8e6c6f0a0b97": "Continue selecting or typing", "39f52eee100131d7": "Cached input", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "Clear storage", "3a5040b68abf75f9": "Select all", "3a8c76b2ce785f96": "Review and import", + "3b67824289b5fa1e": "Dock icon hidden", "3c94b4c75940c178": "Input Tokens", "3cfae5728b92b334": "Token usage: {tokens}", "3d13868593ae4eeb": "Display language", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "No models are available to Cursor yet", "65a6318e07ec1e07": "Tools", "65cb9a7b4f620b6b": "Prompt {tokens}", + "680680288a6d2ad2": "Show in Dock", "68ad603fafe4e0d6": "Import legacy configuration", "68ea5dd4d7af20e6": "System settings", "6a9906c79f26c0ba": "Start time", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "Download and install", "ad6a60ee93d3ba3e": "Loading call details…", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.", "aee88743413144a2": "Refresh", "b06325c5660f0c29": "Direct", "b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.", "b4411558b932266f": "Provider type", "b502b1d414664337": "Prompt: {tokens}", "b5141d3d19e9a048": "Yes", + "b6725f218ebaef26": "Dock icon shown", "b75a46aad3e7c132": "Non-cached input: {tokens}", "b79354009c614ae9": "Statistics", "b86967982067d295": " (estimated)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "Sunday", "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", "f2bdc88464c51c2e": "Show API Key", - "f3a76d896853c1df": "Miss", "f4694c46b1e19602": "Final request type", "f4dcb6a3ceb32247": "Page {page} of {count}", "f4fa9f31ea2ae58d": "Token usage calendar for the past year", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 6682ace..caac4a4 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空", "378bb0eec39fa8a2": "最后一页", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", - "393df9bb13ea4900": "命中", "393e1241552b1870": "请求", "398f8e6c6f0a0b97": "继续选择或输入", "39f52eee100131d7": "缓存输入", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "清理存储空间", "3a5040b68abf75f9": "全选", "3a8c76b2ce785f96": "查看并导入", + "3b67824289b5fa1e": "已隐藏 Dock 栏图标", "3c94b4c75940c178": "输入 Token", "3cfae5728b92b334": "Token 用量:{tokens}", "3d13868593ae4eeb": "界面语言", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "65a6318e07ec1e07": "工具数", "65cb9a7b4f620b6b": "提示词 {tokens}", + "680680288a6d2ad2": "在 Dock 栏显示", "68ad603fafe4e0d6": "导入旧版配置", "68ea5dd4d7af20e6": "系统设置", "6a9906c79f26c0ba": "开始时间", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "下载并安装", "ad6a60ee93d3ba3e": "正在加载调用详情…", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", "aee88743413144a2": "刷新", "b06325c5660f0c29": "直连", "b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。", "b4411558b932266f": "上游类型", "b502b1d414664337": "提示词:{tokens}", "b5141d3d19e9a048": "是", + "b6725f218ebaef26": "已显示 Dock 栏图标", "b75a46aad3e7c132": "非缓存输入:{tokens}", "b79354009c614ae9": "统计数据", "b86967982067d295": "(估算)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "周日", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "f2bdc88464c51c2e": "显示 API Key", - "f3a76d896853c1df": "未命中", "f4694c46b1e19602": "最终请求类型", "f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", diff --git a/apps/desktop/src/layouts/AppFrame.tsx b/apps/desktop/src/layouts/AppFrame.tsx index 969fd1b..5b84a4c 100644 --- a/apps/desktop/src/layouts/AppFrame.tsx +++ b/apps/desktop/src/layouts/AppFrame.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import { desktopPlatform } from "../native/platform"; import styles from "./AppFrame.module.scss"; import { AppHeader } from "./AppHeader"; @@ -17,13 +18,3 @@ export function AppFrame() {
); } - -export type DesktopPlatform = "macos" | "windows" | "linux"; - -function desktopPlatform(): DesktopPlatform { - // return "windows"; - const agent = navigator.userAgent; - if (/Macintosh|Mac OS X/.test(agent)) return "macos"; - if (/Windows/.test(agent)) return "windows"; - return "linux"; -} diff --git a/apps/desktop/src/layouts/AppHeader.tsx b/apps/desktop/src/layouts/AppHeader.tsx index accf2c8..7932fe6 100644 --- a/apps/desktop/src/layouts/AppHeader.tsx +++ b/apps/desktop/src/layouts/AppHeader.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import appIcon from "../../src-tauri/icons/32x32.png"; import { currentAppVersion } from "../native/appLifecycle"; -import type { DesktopPlatform } from "./AppFrame"; +import type { DesktopPlatform } from "../native/platform"; import { WindowControls } from "./WindowControls"; import styles from "./AppHeader.module.scss"; diff --git a/apps/desktop/src/native/appLifecycle.ts b/apps/desktop/src/native/appLifecycle.ts index 8c3f12e..da7379c 100644 --- a/apps/desktop/src/native/appLifecycle.ts +++ b/apps/desktop/src/native/appLifecycle.ts @@ -1,14 +1,19 @@ -import { getVersion } from "@tauri-apps/api/app"; +import { getVersion, setDockVisibility } from "@tauri-apps/api/app"; 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"; +import { api, type DesktopSettings } from "../api"; +import { desktopPlatform } from "./platform"; export function hasNativeAppLifecycle(): boolean { return isTauri(); } +export function hasDockVisibilitySetting(): boolean { + return hasNativeAppLifecycle() && desktopPlatform() === "macos"; +} + export async function currentAppVersion(): Promise { return hasNativeAppLifecycle() ? getVersion() : "dev"; } @@ -21,12 +26,24 @@ 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 readDesktopSettings(): Promise { + return api.desktopSettings(); } export async function writeSilentStart(silentStart: boolean): Promise { - await api.setDesktopSettings({ silent_start: silentStart }); + const settings = await readDesktopSettings(); + await api.setDesktopSettings({ ...settings, silent_start: silentStart }); +} + +export async function writeDockIconVisibility(visible: boolean): Promise { + const settings = await readDesktopSettings(); + await setDockVisibility(visible); + try { + await api.setDesktopSettings({ ...settings, show_dock_icon: visible }); + } catch (cause) { + await setDockVisibility(settings.show_dock_icon).catch(() => {}); + throw cause; + } } export async function checkForUpdate(): Promise { diff --git a/apps/desktop/src/native/platform.ts b/apps/desktop/src/native/platform.ts new file mode 100644 index 0000000..c7c9a9d --- /dev/null +++ b/apps/desktop/src/native/platform.ts @@ -0,0 +1,8 @@ +export type DesktopPlatform = "macos" | "windows" | "linux"; + +export function desktopPlatform(): DesktopPlatform { + const agent = navigator.userAgent; + if (/Macintosh|Mac OS X/.test(agent)) return "macos"; + if (/Windows/.test(agent)) return "windows"; + return "linux"; +} diff --git a/server/src/store/settings.rs b/server/src/store/settings.rs index 3377a46..496cd11 100644 --- a/server/src/store/settings.rs +++ b/server/src/store/settings.rs @@ -47,10 +47,25 @@ pub struct TabSettings { pub address: String, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct DesktopSettings { #[serde(default)] pub silent_start: bool, + #[serde(default = "default_true")] + pub show_dock_icon: bool, +} + +impl Default for DesktopSettings { + fn default() -> Self { + Self { + silent_start: false, + show_dock_icon: true, + } + } +} + +fn default_true() -> bool { + true } impl TabSettings { @@ -324,6 +339,30 @@ mod tests { assert_eq!(store.port_settings().await.unwrap(), settings); } + #[tokio::test] + async fn desktop_settings_show_the_dock_icon_by_default_and_round_trip() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + + assert_eq!( + store.desktop_settings().await.unwrap(), + DesktopSettings::default() + ); + assert_eq!( + serde_json::from_str::(r#"{"silent_start":true}"#).unwrap(), + DesktopSettings { + silent_start: true, + show_dock_icon: true, + } + ); + let settings = DesktopSettings { + silent_start: true, + show_dock_icon: false, + }; + store.set_desktop_settings(settings).await.unwrap(); + + assert_eq!(store.desktop_settings().await.unwrap(), settings); + } + #[tokio::test] async fn proxy_settings_are_write_only_and_preserve_an_unchanged_password() { let store = Store::connect("sqlite::memory:").await.unwrap();