Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-tray-autostart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sfmc-bds/desktop": minor
---

桌面端增加托盘与开机启动,可在客户端设置中开关。
12 changes: 11 additions & 1 deletion docs/zh/guide/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 内容;部署根目录和服务器数据独立于客户端安装目录。

Expand Down
30 changes: 25 additions & 5 deletions packages/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ 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<string, Session>();
const changedHosts = new Map<string, NonNullable<Session["changedFingerprint"]>>();
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 = {
Expand Down Expand Up @@ -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({
Expand All @@ -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));
Expand Down Expand Up @@ -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<DesktopPreferences>);
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();
});
64 changes: 64 additions & 0 deletions packages/desktop/src/main/preferences.ts
Original file line number Diff line number Diff line change
@@ -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<DesktopPreferences> | 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<DesktopPreferences>);
} catch {
return { ...DEFAULTS };
}
}

export function writePreferences(patch: Partial<DesktopPreferences>): 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;
}
}
2 changes: 2 additions & 0 deletions packages/desktop/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<DesktopApi["onEvent"]>[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); },
};
Expand Down
83 changes: 83 additions & 0 deletions packages/desktop/src/main/tray.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
12 changes: 11 additions & 1 deletion packages/desktop/src/renderer/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -208,7 +211,12 @@ function Shell() {
)}
data-platform={appInfo?.platform}
>
<Sidebar collapsed={!layout.sidebar} onToggleSidebar={toggleSidebar} onOpenAbout={openAbout} />
<Sidebar
collapsed={!layout.sidebar}
onToggleSidebar={toggleSidebar}
onOpenAbout={openAbout}
onOpenSettings={openSettings}
/>
<div className="main">
<TitleBar onOpenPalette={() => setPaletteOpen(true)} railOpen={railOpen} onToggleRail={toggleRail} />
<main className="content" id="content">
Expand All @@ -230,12 +238,14 @@ function Shell() {
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onOpenAbout={openAbout}
onOpenSettings={openSettings}
onToggleSidebar={toggleSidebar}
onToggleRail={toggleRail}
/>
<ProfileDialog />
<FlowDialogs />
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
<ClientSettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}
Expand Down
12 changes: 11 additions & 1 deletion packages/desktop/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) {
Expand Down Expand Up @@ -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: "客户端",
Expand All @@ -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())),
Expand Down
10 changes: 10 additions & 0 deletions packages/desktop/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -143,6 +145,14 @@ export function Sidebar({
</Tooltip>
);
})}
{group === "维护" && (
<Tooltip content={collapsed ? "设置" : undefined} side="right">
<button type="button" className="sb-item" aria-label="设置" onClick={onOpenSettings}>
<Icon name="settings" size={20} weight="duotone" />
<span className="sb-item-label">设置</span>
</button>
</Tooltip>
)}
{group === "维护" && (
<Tooltip content={collapsed ? "关于" : undefined} side="right">
<button
Expand Down
Loading
Loading