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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user