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
|
export type DesktopAppSettingKey = keyof DesktopAppSettings
|
||||||
|
|
||||||
|
interface DesktopAppInternalFlags {
|
||||||
|
hasShownBackgroundBalloon: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = {
|
const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = {
|
||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
keepRunningInBackground: true,
|
keepRunningInBackground: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = {
|
||||||
|
hasShownBackgroundBalloon: false,
|
||||||
|
}
|
||||||
|
|
||||||
let cachedSettings: DesktopAppSettings = { ...DEFAULT_DESKTOP_APP_SETTINGS }
|
let cachedSettings: DesktopAppSettings = { ...DEFAULT_DESKTOP_APP_SETTINGS }
|
||||||
|
let cachedFlags: DesktopAppInternalFlags = { ...DEFAULT_INTERNAL_FLAGS }
|
||||||
|
|
||||||
function settingsPath(): string {
|
function settingsPath(): string {
|
||||||
return join(app.getPath('userData'), 'desktop-settings.json')
|
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') {
|
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 {
|
return {
|
||||||
openAtLogin:
|
settings: {
|
||||||
typeof candidate.openAtLogin === 'boolean'
|
openAtLogin:
|
||||||
? candidate.openAtLogin
|
typeof candidate.openAtLogin === 'boolean'
|
||||||
: DEFAULT_DESKTOP_APP_SETTINGS.openAtLogin,
|
? candidate.openAtLogin
|
||||||
keepRunningInBackground:
|
: DEFAULT_DESKTOP_APP_SETTINGS.openAtLogin,
|
||||||
typeof candidate.keepRunningInBackground === 'boolean'
|
keepRunningInBackground:
|
||||||
? candidate.keepRunningInBackground
|
typeof candidate.keepRunningInBackground === 'boolean'
|
||||||
: DEFAULT_DESKTOP_APP_SETTINGS.keepRunningInBackground,
|
? 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 {
|
try {
|
||||||
return normalizeSettings(JSON.parse(readFileSync(settingsPath(), 'utf8')))
|
return normalizePersistedFile(JSON.parse(readFileSync(settingsPath(), 'utf8')))
|
||||||
} catch {
|
} 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 {
|
try {
|
||||||
writeFileSync(settingsPath(), JSON.stringify(settings), 'utf8')
|
writeFileSync(
|
||||||
|
settingsPath(),
|
||||||
|
JSON.stringify({ ...cachedSettings, ...cachedFlags }),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desktop-settings] persist failed', 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 {
|
function applyOpenAtLogin(openAtLogin: boolean): boolean {
|
||||||
try {
|
try {
|
||||||
app.setLoginItemSettings({
|
if (process.platform === 'darwin') {
|
||||||
openAtLogin,
|
app.setLoginItemSettings({
|
||||||
openAsHidden: false,
|
openAtLogin,
|
||||||
})
|
openAsHidden: false,
|
||||||
|
})
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin,
|
||||||
|
path: process.execPath,
|
||||||
|
args: [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
app.setLoginItemSettings({ openAtLogin })
|
||||||
|
}
|
||||||
return readSystemOpenAtLogin(openAtLogin)
|
return readSystemOpenAtLogin(openAtLogin)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desktop-settings] apply login item failed', error)
|
console.warn('[desktop-settings] apply login item failed', error)
|
||||||
@@ -78,12 +139,13 @@ function applyOpenAtLogin(openAtLogin: boolean): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initDesktopAppSettings(): DesktopAppSettings {
|
export function initDesktopAppSettings(): DesktopAppSettings {
|
||||||
cachedSettings = readPersistedSettings()
|
const { settings, flags } = readPersistedFile()
|
||||||
cachedSettings = {
|
cachedSettings = {
|
||||||
...cachedSettings,
|
...settings,
|
||||||
openAtLogin: applyOpenAtLogin(cachedSettings.openAtLogin),
|
openAtLogin: applyOpenAtLogin(settings.openAtLogin),
|
||||||
}
|
}
|
||||||
persistSettings(cachedSettings)
|
cachedFlags = flags
|
||||||
|
persistFile()
|
||||||
return getDesktopAppSettings()
|
return getDesktopAppSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +171,18 @@ export function setDesktopAppSetting(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedSettings = next
|
cachedSettings = next
|
||||||
persistSettings(cachedSettings)
|
persistFile()
|
||||||
return getDesktopAppSettings()
|
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'
|
} from './app-issue-indicator'
|
||||||
import {
|
import {
|
||||||
getDesktopAppSettings,
|
getDesktopAppSettings,
|
||||||
|
hasShownBackgroundBalloon,
|
||||||
initDesktopAppSettings,
|
initDesktopAppSettings,
|
||||||
|
markBackgroundBalloonShown,
|
||||||
setDesktopAppSetting,
|
setDesktopAppSetting,
|
||||||
type DesktopAppSettingKey,
|
type DesktopAppSettingKey,
|
||||||
} from './app-settings'
|
} from './app-settings'
|
||||||
@@ -50,7 +52,7 @@ import {
|
|||||||
listDesktopPublishTasks,
|
listDesktopPublishTasks,
|
||||||
retryDesktopPublishTask,
|
retryDesktopPublishTask,
|
||||||
} from './transport/api-client'
|
} from './transport/api-client'
|
||||||
import { initTray } from './tray'
|
import { initTray, showTrayBalloon } from './tray'
|
||||||
import { STANDARD_USER_AGENT } from './user-agent'
|
import { STANDARD_USER_AGENT } from './user-agent'
|
||||||
import { startHotViewReaper } from './view-pool'
|
import { startHotViewReaper } from './view-pool'
|
||||||
|
|
||||||
@@ -64,6 +66,14 @@ app.commandLine.appendSwitch('disable-background-networking')
|
|||||||
app.commandLine.appendSwitch('remote-debugging-port', String(getPlaywrightCDPPort()))
|
app.commandLine.appendSwitch('remote-debugging-port', String(getPlaywrightCDPPort()))
|
||||||
app.userAgentFallback = STANDARD_USER_AGENT
|
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
|
const isDevelopmentRuntime = !app.isPackaged
|
||||||
|
|
||||||
function silencePackagedConsole(): void {
|
function silencePackagedConsole(): void {
|
||||||
@@ -598,6 +608,12 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
|
|||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
window.hide()
|
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)
|
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 {
|
export function updateTrayIssueIndicator(issueCount: number): void {
|
||||||
if (!tray || tray.isDestroyed()) {
|
if (!tray || tray.isDestroyed()) {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user