diff --git a/.changeset/desktop-tray-autostart.md b/.changeset/desktop-tray-autostart.md new file mode 100644 index 00000000..726e6cef --- /dev/null +++ b/.changeset/desktop-tray-autostart.md @@ -0,0 +1,5 @@ +--- +"@sfmc-bds/desktop": minor +--- + +桌面端增加托盘与开机启动,可在客户端设置中开关。 diff --git a/docs/zh/guide/desktop.md b/docs/zh/guide/desktop.md index 875da407..e1fbd92d 100644 --- a/docs/zh/guide/desktop.md +++ b/docs/zh/guide/desktop.md @@ -77,12 +77,22 @@ gh attestation verify '.\SFMC-Desktop-0.2.0-x64.exe' --repo DogeLakeDev/ScriptsF 浏览器中的 Studio 工程与桌面客户端分别保存;已有浏览器工程请先导出 ZIP,再导入内置编辑器。导出的工程需按模块开发流程放回模块中,编辑器不会自动修改或部署服务器文件。 +## 客户端设置 + +侧栏 **维护** 中的 **设置**,或命令面板中的 **客户端设置**,可以调整本机客户端行为。这些选项保存在当前电脑,不随实例同步。 + +- 托盘图标默认开启。 +- 关闭窗口时默认留在托盘,不会退出客户端,也不会停止服务器。要退出,使用托盘菜单中的 **退出**。 +- 最小化到托盘默认关闭。 +- 开机启动默认关闭。只有安装版会写入系统登录项。 +- 开机启动时隐藏窗口需要同时开启托盘和开机启动。 + ## 建议的首次验收 1. 先在独立测试目录完成接入;已有正式部署优先选择 **仅查看**。 2. 在概览先启动数据服务,再启动 BDS。确认日志没有初始化错误,指标有新采样。 3. 查看模块与配置,尝试一项可撤销的修改,并确认任务完成。 -4. 关闭客户端后重新打开,确认能连接原守护进程。关闭窗口不会停止服务器;需要停止时在概览明确停止 BDS 和数据服务。 +4. 从托盘退出客户端后重新打开,确认能连接原守护进程。关闭窗口默认只是留在托盘,不会停止服务器;需要停止时在概览明确停止 BDS 和数据服务。 本版本通过发行页手动更新。后续版本从发行页下载,退出客户端后安装到原客户端目录,或完整替换 ZIP 内容;部署根目录和服务器数据独立于客户端安装目录。 diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index d1ad285a..7d6fa3d4 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -6,9 +6,11 @@ import { autoUpdater } from "electron-updater"; import { releaseManifest } from "./runtime.js"; import { Session } from "./sessions.js"; import { profiles, profile, saveProfile, removeProfile, credentials, rememberSession } from "./profiles.js"; -import type { AppearanceMode, Credentials, InstanceProfile } from "../shared/api.js"; +import type { AppearanceMode, Credentials, DesktopPreferences, InstanceProfile } from "../shared/api.js"; import type { ManagementMethod } from "@sfmc-bds/management"; import { desktopReleaseNotes } from "@sfmc-bds/management/node"; +import { applyLoginItem, readPreferences, shouldStartHidden, writePreferences } from "./preferences.js"; +import { bindWindow, markQuitting, showWindow, syncTray } from "./tray.js"; const sessions = new Map(); const changedHosts = new Map>(); @@ -16,7 +18,7 @@ let window: BrowserWindow; app.setName("SFMC Desktop"); if (!app.isPackaged && process.env.SFMC_DESKTOP_USER_DATA) app.setPath("userData", process.env.SFMC_DESKTOP_USER_DATA); if (!app.requestSingleInstanceLock()) process.exit(0); -app.on("second-instance", () => { window?.show(); window?.focus(); }); +app.on("second-instance", () => { if (window && !window.isDestroyed()) showWindow(window); }); /** SnowUI 窗口底色:浅色 Background/1、深色 Background/1 */ const THEME_CHROME = { @@ -44,6 +46,9 @@ function applyChrome(mode: AppearanceMode) { void app.whenReady().then(async () => { if (app.isPackaged) Menu.setApplicationMenu(null); + const launchPrefs = readPreferences(); + applyLoginItem(launchPrefs); + const launchHidden = shouldStartHidden(launchPrefs); const initialDark = nativeTheme.shouldUseDarkColors; const chrome = THEME_CHROME[initialDark ? "dark" : "light"]; window = new BrowserWindow({ @@ -59,7 +64,10 @@ void app.whenReady().then(async () => { }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("will-navigate", event => event.preventDefault()); - window.once("ready-to-show", () => window.show()); + window.once("ready-to-show", () => { if (!launchHidden) window.show(); }); + bindWindow(window); + const hasTray = syncTray(window); + if (launchHidden && !hasTray) window.show(); const handle = (channel: string, action: (...args: unknown[]) => unknown) => ipcMain.handle(`sfmc:${channel}`, (event, ...args: unknown[]) => { if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("非法调用来源"); return action(...args); }); handle("profiles", () => profiles()); handle("saveProfile", (input, secret) => saveProfile(input as InstanceProfile, secret as Credentials)); @@ -128,14 +136,26 @@ void app.whenReady().then(async () => { }); /** 客户端版本与运行形态(关于对话框、侧栏页脚) */ handle("appInfo", () => ({ version: app.getVersion(), platform: process.platform, packaged: app.isPackaged })); + handle("preferences", () => readPreferences()); + handle("setPreferences", patch => { + const next = writePreferences(patch as Partial); + applyLoginItem(next); + syncTray(window); + return next; + }); nativeTheme.on("updated", () => { if (nativeTheme.themeSource === "system") applyChrome("system"); + syncTray(window); }); const dev = !app.isPackaged ? process.env.SFMC_DESKTOP_DEV_URL : undefined; if (dev && /^http:\/\/127\.0\.0\.1:\d+$/.test(dev)) await window.loadURL(dev); else await window.loadFile(path.join(__dirname, "renderer", "index.html")); // 部分 Windows 环境未触发 ready-to-show;页面加载完成后仍应显示主窗口。 - if (!window.isVisible()) window.show(); + if (!launchHidden && !window.isVisible()) window.show(); }); app.on("window-all-closed", () => app.quit()); -app.on("before-quit", () => { for (const session of sessions.values()) session.disconnect(); }); +app.on("activate", () => { if (window && !window.isDestroyed()) showWindow(window); }); +app.on("before-quit", () => { + markQuitting(); + for (const session of sessions.values()) session.disconnect(); +}); diff --git a/packages/desktop/src/main/preferences.ts b/packages/desktop/src/main/preferences.ts new file mode 100644 index 00000000..3bcf26f2 --- /dev/null +++ b/packages/desktop/src/main/preferences.ts @@ -0,0 +1,64 @@ +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import type { DesktopPreferences } from "../shared/api.js"; + +const DEFAULTS: DesktopPreferences = { + tray: true, + closeToTray: true, + minimizeToTray: false, + openAtLogin: false, + startHidden: false, +}; + +const KEYS = ["tray", "closeToTray", "minimizeToTray", "openAtLogin", "startHidden"] as const; + +function file(): string { + return path.join(app.getPath("userData"), "preferences.json"); +} + +function normalize(value: Partial | null | undefined): DesktopPreferences { + const next = { ...DEFAULTS }; + if (!value) return next; + for (const key of KEYS) if (typeof value[key] === "boolean") next[key] = value[key]; + return next; +} + +export function readPreferences(): DesktopPreferences { + try { + return normalize(JSON.parse(fs.readFileSync(file(), "utf8")) as Partial); + } catch { + return { ...DEFAULTS }; + } +} + +export function writePreferences(patch: Partial): DesktopPreferences { + const next = normalize({ ...readPreferences(), ...patch }); + fs.mkdirSync(path.dirname(file()), { recursive: true }); + fs.writeFileSync(file(), JSON.stringify(next, null, 2) + "\n"); + return next; +} + +/** 把开机启动同步到系统。开发模式不写登录项,避免把 Electron 开发进程登记进去。 */ +export function applyLoginItem(prefs: DesktopPreferences): void { + if (!app.isPackaged) return; + const args = prefs.openAtLogin && prefs.startHidden ? ["--sfmc-hidden"] : []; + try { + app.setLoginItemSettings({ + openAtLogin: prefs.openAtLogin, + ...(process.platform === "win32" ? { path: process.execPath, args } : {}), + }); + } catch { + /* 当前环境不支持登录项时保留偏好,下次启动再试 */ + } +} + +export function shouldStartHidden(prefs: DesktopPreferences): boolean { + if (!prefs.tray || !prefs.startHidden) return false; + if (process.argv.includes("--sfmc-hidden")) return true; + try { + return prefs.openAtLogin && app.getLoginItemSettings().wasOpenedAtLogin; + } catch { + return false; + } +} diff --git a/packages/desktop/src/main/preload.ts b/packages/desktop/src/main/preload.ts index 6db0d478..704a40f3 100644 --- a/packages/desktop/src/main/preload.ts +++ b/packages/desktop/src/main/preload.ts @@ -14,6 +14,8 @@ const api: DesktopApi = { openLink: (kind, url) => ipcRenderer.invoke("sfmc:openLink", kind, url), appearance: mode => ipcRenderer.invoke("sfmc:appearance", mode), appInfo: () => ipcRenderer.invoke("sfmc:appInfo"), + preferences: () => ipcRenderer.invoke("sfmc:preferences"), + setPreferences: patch => ipcRenderer.invoke("sfmc:setPreferences", patch), onEvent: callback => { const listener = (_event: unknown, id: string, event: Parameters[0]>[1]) => callback(id, event); ipcRenderer.on("sfmc:event", listener); return () => ipcRenderer.removeListener("sfmc:event", listener); }, onConnection: callback => { const listener = (_event: unknown, id: string, message: string) => callback(id, message); ipcRenderer.on("sfmc:connection", listener); return () => ipcRenderer.removeListener("sfmc:connection", listener); }, }; diff --git a/packages/desktop/src/main/tray.ts b/packages/desktop/src/main/tray.ts new file mode 100644 index 00000000..6a62a55c --- /dev/null +++ b/packages/desktop/src/main/tray.ts @@ -0,0 +1,83 @@ +import { app, BrowserWindow, Menu, nativeImage, nativeTheme, Tray } from "electron"; +import { readPreferences } from "./preferences.js"; + +const LIGHT_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAe0lEQVR4nO3OMQ7AIAxD0Rwk9+J23JK9C0MVtQWcQKTKg1f/J6oqmUuNE0DALwCtLwXQzI4BbNgFiQxDkB3hJcgMoPathktfGGAWUszCAV8IG98GsJCn8BFAHcQJICAEMIJAYQTwBoHCHoCFQOEIgNwA8IcX4B4BBBBwAYaM1wFV/IdBAAAAAElFTkSuQmCC"; +const DARK_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAfElEQVR4nO3OMQ7AIAxDUW7J7ThhVakDO0uGKmoLOIFIlQev/i8d55UiFxongIBfAKosBFDVtgF02ATxDEOQFeEpyAigyGbDWeYGGIVkNXfAF0LHlwE05Cm8BVA6cQIIcAH0IFAYAbxBoLAFoCFQ2AOQbgD4wwowjwACCGjJdbOXvAiXBAAAAABJRU5ErkJggg=="; + +let tray: Tray | null = null; +let quitting = false; + +export function markQuitting(): void { + quitting = true; +} + +export function hasTray(): boolean { + return Boolean(tray && !tray.isDestroyed()); +} + +export function showWindow(window: BrowserWindow): void { + if (window.isDestroyed()) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); +} + +function icon() { + const image = nativeImage.createFromDataURL(nativeTheme.shouldUseDarkColors ? DARK_URL : LIGHT_URL); + return process.platform === "win32" ? image.resize({ width: 16, height: 16 }) : image; +} + +export function syncTray(window: BrowserWindow): boolean { + const prefs = readPreferences(); + if (!prefs.tray) { + const hadTray = Boolean(tray && !tray.isDestroyed()); + tray?.destroy(); + tray = null; + if (hadTray && !window.isDestroyed() && !window.isVisible()) showWindow(window); + return false; + } + if (!tray || tray.isDestroyed()) { + try { + tray = new Tray(icon()); + } catch { + tray = null; + return false; + } + tray.setToolTip("SFMC Desktop"); + const open = () => showWindow(window); + tray.on("click", open); + tray.on("double-click", open); + } else { + tray.setImage(icon()); + } + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: "显示窗口", click: () => showWindow(window) }, + { type: "separator" }, + { + label: "退出", + click: () => { + markQuitting(); + app.quit(); + }, + }, + ]) + ); + return true; +} + +export function bindWindow(window: BrowserWindow): void { + window.on("close", (event) => { + const prefs = readPreferences(); + if (quitting || !prefs.tray || !prefs.closeToTray || !hasTray()) return; + event.preventDefault(); + window.hide(); + }); + window.on("minimize", () => { + const prefs = readPreferences(); + if (quitting || !prefs.tray || !prefs.minimizeToTray || !hasTray()) return; + window.hide(); + }); +} diff --git a/packages/desktop/src/renderer/app/App.tsx b/packages/desktop/src/renderer/app/App.tsx index 618a9dd5..1dc7bbda 100644 --- a/packages/desktop/src/renderer/app/App.tsx +++ b/packages/desktop/src/renderer/app/App.tsx @@ -19,6 +19,7 @@ import { TitleBar } from "../components/TitleBar.js"; import { TooltipProvider } from "../components/tooltip.js"; import { Callout } from "../components/ui.js"; import { AboutDialog } from "../dialogs/AboutDialog.js"; +import { ClientSettingsDialog } from "../dialogs/ClientSettingsDialog.js"; import { FlowDialogs } from "../dialogs/FlowDialogs.js"; import { ProfileDialog } from "../dialogs/ProfileDialog.js"; import { cx } from "../lib/cx.js"; @@ -146,6 +147,7 @@ function Shell() { const [railOverlay, setRailOverlay] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [studioOpened, setStudioOpened] = useState(page === "studio"); // 编辑器首次打开后保留挂载,切换页面或实例不会丢失当前草稿、选中项与撤销记录。 useEffect(() => { @@ -171,6 +173,7 @@ function Shell() { else setLayout((value) => ({ ...value, rail: !value.rail })); }, [narrow]); const openAbout = useCallback(() => setAboutOpen(true), []); + const openSettings = useCallback(() => setSettingsOpen(true), []); useEffect(() => { const listener = (event: KeyboardEvent) => { @@ -208,7 +211,12 @@ function Shell() { )} data-platform={appInfo?.platform} > - +
setPaletteOpen(true)} railOpen={railOpen} onToggleRail={toggleRail} />
@@ -230,12 +238,14 @@ function Shell() { open={paletteOpen} onClose={() => setPaletteOpen(false)} onOpenAbout={openAbout} + onOpenSettings={openSettings} onToggleSidebar={toggleSidebar} onToggleRail={toggleRail} /> setAboutOpen(false)} /> + setSettingsOpen(false)} />
); } diff --git a/packages/desktop/src/renderer/components/CommandPalette.tsx b/packages/desktop/src/renderer/components/CommandPalette.tsx index f9eadbad..0870b76a 100644 --- a/packages/desktop/src/renderer/components/CommandPalette.tsx +++ b/packages/desktop/src/renderer/components/CommandPalette.tsx @@ -51,12 +51,14 @@ export function CommandPalette({ open, onClose, onOpenAbout, + onOpenSettings, onToggleSidebar, onToggleRail, }: { open: boolean; onClose: () => void; onOpenAbout: () => void; + onOpenSettings: () => void; onToggleSidebar?: () => void; onToggleRail?: () => void; }) { @@ -223,6 +225,14 @@ export function CommandPalette({ keywords: "rail activity layout 动态 布局", run: onToggleRail, }); + rows.push({ + id: "app:settings", + group: "客户端", + label: "客户端设置", + icon: "settings", + keywords: "settings tray 托盘 开机启动 设置", + run: onOpenSettings, + }); rows.push({ id: "app:about", group: "客户端", @@ -232,7 +242,7 @@ export function CommandPalette({ run: onOpenAbout, }); return rows; - }, [desktop, setMode, onOpenAbout, onToggleSidebar, onToggleRail]); + }, [desktop, setMode, onOpenAbout, onOpenSettings, onToggleSidebar, onToggleRail]); const visible = useMemo( () => commands.filter((command) => !query.trim() || matches(command, query.trim())), diff --git a/packages/desktop/src/renderer/components/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar.tsx index 3b64c5ca..ea97733d 100644 --- a/packages/desktop/src/renderer/components/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar.tsx @@ -94,10 +94,12 @@ export function Sidebar({ collapsed, onToggleSidebar, onOpenAbout, + onOpenSettings, }: { collapsed: boolean; onToggleSidebar: () => void; onOpenAbout: () => void; + onOpenSettings: () => void; }) { const { model, page, setPage, desktopUpdate } = useDesktop(); const hasDesktopUpdate = desktopUpdate.info?.available === true; @@ -143,6 +145,14 @@ export function Sidebar({ ); })} + {group === "维护" && ( + + + + )} {group === "维护" && (