feat(desktop-client): split autostart and keep-alive per platform

Refactors openAtLogin and keepRunningInBackground to make the macOS/Windows
divergence explicit instead of leaning on Electron's cross-platform
abstraction:

- openAtLogin on Windows now passes path: process.execPath and args: []
  so the registry HKCU Run key always points at the right binary.
- keepRunningInBackground on Windows shows a one-shot tray balloon ("省心
  推已最小化到托盘…") so first-time users notice the app is still alive;
  persisted via a private hasShownBackgroundBalloon flag so it never fires
  again. macOS preserves the dock icon and stays silent.
- Sets AppUserModelID early on Windows so tray.displayBalloon's Win10+
  Toast routing actually fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 18:21:08 +08:00
parent de63d0be5b
commit a4c654782b
3 changed files with 138 additions and 26 deletions
+99 -25
View File
@@ -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<Record<DesktopAppSettingKey, unknown>>
const candidate = input as Partial<
Record<DesktopAppSettingKey | keyof DesktopAppInternalFlags, unknown>
>
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()
}
+17 -1
View File
@@ -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<ElectronBrowserWindow>
) {
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()
}
}
})
+22
View File
@@ -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