diff --git a/apps/desktop-client/src/main/app-settings.ts b/apps/desktop-client/src/main/app-settings.ts index b0e3cb8..711b91b 100644 --- a/apps/desktop-client/src/main/app-settings.ts +++ b/apps/desktop-client/src/main/app-settings.ts @@ -10,46 +10,81 @@ export interface DesktopAppSettings { export type DesktopAppSettingKey = keyof DesktopAppSettings +interface DesktopAppInternalFlags { + hasShownBackgroundBalloon: boolean +} + const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = { openAtLogin: false, keepRunningInBackground: true, } +const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = { + hasShownBackgroundBalloon: false, +} + let cachedSettings: DesktopAppSettings = { ...DEFAULT_DESKTOP_APP_SETTINGS } +let cachedFlags: DesktopAppInternalFlags = { ...DEFAULT_INTERNAL_FLAGS } function settingsPath(): string { return join(app.getPath('userData'), 'desktop-settings.json') } -function normalizeSettings(input: unknown): DesktopAppSettings { +function normalizePersistedFile(input: unknown): { + settings: DesktopAppSettings + flags: DesktopAppInternalFlags +} { if (!input || typeof input !== 'object') { - return { ...DEFAULT_DESKTOP_APP_SETTINGS } + return { + settings: { ...DEFAULT_DESKTOP_APP_SETTINGS }, + flags: { ...DEFAULT_INTERNAL_FLAGS }, + } } - const candidate = input as Partial> + const candidate = input as Partial< + Record + > return { - openAtLogin: - typeof candidate.openAtLogin === 'boolean' - ? candidate.openAtLogin - : DEFAULT_DESKTOP_APP_SETTINGS.openAtLogin, - keepRunningInBackground: - typeof candidate.keepRunningInBackground === 'boolean' - ? candidate.keepRunningInBackground - : DEFAULT_DESKTOP_APP_SETTINGS.keepRunningInBackground, + settings: { + openAtLogin: + typeof candidate.openAtLogin === 'boolean' + ? candidate.openAtLogin + : DEFAULT_DESKTOP_APP_SETTINGS.openAtLogin, + keepRunningInBackground: + typeof candidate.keepRunningInBackground === 'boolean' + ? candidate.keepRunningInBackground + : DEFAULT_DESKTOP_APP_SETTINGS.keepRunningInBackground, + }, + flags: { + hasShownBackgroundBalloon: + typeof candidate.hasShownBackgroundBalloon === 'boolean' + ? candidate.hasShownBackgroundBalloon + : DEFAULT_INTERNAL_FLAGS.hasShownBackgroundBalloon, + }, } } -function readPersistedSettings(): DesktopAppSettings { +function readPersistedFile(): { + settings: DesktopAppSettings + flags: DesktopAppInternalFlags +} { try { - return normalizeSettings(JSON.parse(readFileSync(settingsPath(), 'utf8'))) + return normalizePersistedFile(JSON.parse(readFileSync(settingsPath(), 'utf8'))) } catch { - return { ...DEFAULT_DESKTOP_APP_SETTINGS } + return { + settings: { ...DEFAULT_DESKTOP_APP_SETTINGS }, + flags: { ...DEFAULT_INTERNAL_FLAGS }, + } } } -function persistSettings(settings: DesktopAppSettings): void { +function persistFile(): void { try { - writeFileSync(settingsPath(), JSON.stringify(settings), 'utf8') + writeFileSync( + settingsPath(), + JSON.stringify({ ...cachedSettings, ...cachedFlags }), + 'utf8', + ) } catch (error) { console.warn('[desktop-settings] persist failed', error) } @@ -64,12 +99,38 @@ function readSystemOpenAtLogin(fallback: boolean): boolean { } } +// Platform-specific login-item application. +// +// macOS — `setLoginItemSettings({ openAtLogin })` registers a LaunchAgent via +// the ServiceManagement framework. `path`/`args` are ignored on darwin; the +// system always launches the bundled .app. We keep `openAsHidden: false` +// because the user wants the main window to appear at login. +// +// Windows — `setLoginItemSettings` writes to +// `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. In dev the registry +// would point at electron.exe, so we explicitly pass `path: process.execPath` +// and an empty `args: []` so the production NSIS-installed exe is registered +// correctly. No `--hidden` flag because the user wants a normal window at +// startup. +// +// Linux — `setLoginItemSettings` is a no-op in Electron; we silently accept +// the value but the OS will not honor it. function applyOpenAtLogin(openAtLogin: boolean): boolean { try { - app.setLoginItemSettings({ - openAtLogin, - openAsHidden: false, - }) + if (process.platform === 'darwin') { + app.setLoginItemSettings({ + openAtLogin, + openAsHidden: false, + }) + } else if (process.platform === 'win32') { + app.setLoginItemSettings({ + openAtLogin, + path: process.execPath, + args: [], + }) + } else { + app.setLoginItemSettings({ openAtLogin }) + } return readSystemOpenAtLogin(openAtLogin) } catch (error) { console.warn('[desktop-settings] apply login item failed', error) @@ -78,12 +139,13 @@ function applyOpenAtLogin(openAtLogin: boolean): boolean { } export function initDesktopAppSettings(): DesktopAppSettings { - cachedSettings = readPersistedSettings() + const { settings, flags } = readPersistedFile() cachedSettings = { - ...cachedSettings, - openAtLogin: applyOpenAtLogin(cachedSettings.openAtLogin), + ...settings, + openAtLogin: applyOpenAtLogin(settings.openAtLogin), } - persistSettings(cachedSettings) + cachedFlags = flags + persistFile() return getDesktopAppSettings() } @@ -109,6 +171,18 @@ export function setDesktopAppSetting( } cachedSettings = next - persistSettings(cachedSettings) + persistFile() return getDesktopAppSettings() } + +export function hasShownBackgroundBalloon(): boolean { + return cachedFlags.hasShownBackgroundBalloon +} + +export function markBackgroundBalloonShown(): void { + if (cachedFlags.hasShownBackgroundBalloon) { + return + } + cachedFlags = { ...cachedFlags, hasShownBackgroundBalloon: true } + persistFile() +} diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 6b71abc..e59a0ab 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -20,7 +20,9 @@ import { } from './app-issue-indicator' import { getDesktopAppSettings, + hasShownBackgroundBalloon, initDesktopAppSettings, + markBackgroundBalloonShown, setDesktopAppSetting, type DesktopAppSettingKey, } from './app-settings' @@ -50,7 +52,7 @@ import { listDesktopPublishTasks, retryDesktopPublishTask, } from './transport/api-client' -import { initTray } from './tray' +import { initTray, showTrayBalloon } from './tray' import { STANDARD_USER_AGENT } from './user-agent' import { startHotViewReaper } from './view-pool' @@ -64,6 +66,14 @@ app.commandLine.appendSwitch('disable-background-networking') app.commandLine.appendSwitch('remote-debugging-port', String(getPlaywrightCDPPort())) app.userAgentFallback = STANDARD_USER_AGENT +// Windows 10+ routes tray.displayBalloon through the Toast Notification API, +// which requires a registered AppUserModelID. electron-builder writes this on +// the Start Menu shortcut at install time, but we set it explicitly so dev +// runs and edge-case installs also fire the balloon reliably. +if (process.platform === 'win32') { + app.setAppUserModelId('com.geo.rankly.desktop') +} + const isDevelopmentRuntime = !app.isPackaged function silencePackagedConsole(): void { @@ -598,6 +608,12 @@ async function createAppWindow(mode: WindowMode): Promise ) { event.preventDefault() window.hide() + // Windows: notify users once that the app is still running in the tray. + // displayBalloon is a no-op on macOS/Linux so the guard is in tray.ts. + if (process.platform === 'win32' && !hasShownBackgroundBalloon()) { + showTrayBalloon('省心推已最小化到托盘', '应用仍在后台运行,右键托盘图标可退出。') + markBackgroundBalloonShown() + } } }) diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts index d90d7cd..2476134 100644 --- a/apps/desktop-client/src/main/tray.ts +++ b/apps/desktop-client/src/main/tray.ts @@ -47,6 +47,28 @@ function formatIssueCount(count: number): string { return count > 99 ? '99+' : String(count) } +// Windows-only: tray.displayBalloon shows a system toast next to the tray +// icon. macOS and Linux ignore this call (Electron returns silently), so the +// guard here is just to avoid the no-op log noise on non-Windows. +export function showTrayBalloon(title: string, content: string): void { + if (process.platform !== 'win32') { + return + } + if (!tray || tray.isDestroyed()) { + return + } + try { + tray.displayBalloon({ + title, + content, + icon: createTrayIcon('normal'), + iconType: 'custom', + }) + } catch (error) { + console.warn('[desktop-tray] displayBalloon failed', error) + } +} + export function updateTrayIssueIndicator(issueCount: number): void { if (!tray || tray.isDestroyed()) { return