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()
}