fix(stage-tamagotchi): prevent duplicate desktop instances (#1815)

This commit is contained in:
Lovehsigure_520
2026-05-19 19:13:20 +08:00
committed by GitHub
parent 4549be1601
commit c0ced4ed17
3 changed files with 156 additions and 2 deletions
@@ -0,0 +1,78 @@
import type { App, BrowserWindow } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const windowMock = vi.hoisted(() => ({
toggleWindowShow: vi.fn(),
}))
vi.mock('../windows/shared/window', () => ({
toggleWindowShow: windowMock.toggleWindowShow,
}))
function createMockApp(hasSingleInstanceLock: boolean): MockApp {
return {
on: vi.fn(),
quit: vi.fn(),
requestSingleInstanceLock: vi.fn(() => hasSingleInstanceLock),
} as unknown as MockApp
}
function createMockWindow() {
return {} as BrowserWindow
}
describe('installSingleInstanceGuard', async () => {
const { installSingleInstanceGuard } = await import('./single-instance')
beforeEach(() => {
vi.clearAllMocks()
})
/**
* @example
* const installed = installSingleInstanceGuard({ app, getWindow })
* expect(installed).toBe(false)
*/
it('quits the secondary process when another AIRI instance already owns the lock', () => {
const app = createMockApp(false)
const installed = installSingleInstanceGuard({
app,
getWindow: vi.fn(() => undefined),
})
expect(installed).toBe(false)
expect(app.requestSingleInstanceLock).toHaveBeenCalledOnce()
expect(app.quit).toHaveBeenCalledOnce()
expect(app.on).not.toHaveBeenCalled()
})
/**
* @example
* secondInstanceHandler()
* expect(toggleWindowShow).toHaveBeenCalledWith(window)
*/
it('shows the main window when Windows forwards a second launch to the primary process', () => {
const app = createMockApp(true)
const window = createMockWindow()
const installed = installSingleInstanceGuard({
app,
getWindow: vi.fn(() => window),
})
expect(installed).toBe(true)
expect(app.on).toHaveBeenCalledWith('second-instance', expect.any(Function))
const secondInstanceHandler = app.on.mock.calls[0]?.[1] as () => void
secondInstanceHandler()
expect(windowMock.toggleWindowShow).toHaveBeenCalledWith(window)
})
})
type MockApp = App & {
on: ReturnType<typeof vi.fn>
quit: ReturnType<typeof vi.fn>
requestSingleInstanceLock: ReturnType<typeof vi.fn>
}
@@ -0,0 +1,57 @@
import type { App, BrowserWindow } from 'electron'
import { toggleWindowShow } from '../windows/shared/window'
interface SingleInstanceGuardOptions {
app: App
getWindow: () => BrowserWindow | undefined
}
/**
* Focuses the main AIRI window after a duplicate launch.
*
* Use when:
* - Electron forwards a second process launch to the primary instance
* - The app should show the already-running UI instead of starting another runtime
*
* Expects:
* - `getWindow` returns the main user-facing window when it has been created
*
* Returns:
* - N/A
*/
function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) {
const window = getWindow()
if (!window) {
return
}
toggleWindowShow(window)
}
/**
* Installs Electron's single-instance guard for the desktop runtime.
*
* Use when:
* - Only one AIRI desktop process should own local runtime resources
* - Fixed localhost services such as the server channel must not bind twice
*
* Expects:
* - The guard is installed before `app.whenReady()` starts runtime services
*
* Returns:
* - `true` for the primary process, `false` after requesting shutdown for a secondary process
*/
export function installSingleInstanceGuard(options: SingleInstanceGuardOptions) {
const hasSingleInstanceLock = options.app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
options.app.quit()
return false
}
options.app.on('second-instance', () => {
focusMainWindow(options.getWindow)
})
return true
}
+21 -2
View File
@@ -1,3 +1,5 @@
import type { BrowserWindow } from 'electron'
import type { FileLoggerHandle } from './app/file-logger'
import process, { env, platform } from 'node:process'
@@ -20,6 +22,7 @@ import icon from '../../resources/icon.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger'
import { installSingleInstanceGuard } from './app/single-instance'
import { createArtistryConfig } from './configs/artistry'
import { createGlobalAppConfig } from './configs/global'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
@@ -93,12 +96,23 @@ if (isLinux) {
app.dock?.setIcon(icon)
electronApp.setAppUserModelId('ai.moeru.airi')
initScreenCaptureForMain()
// Track the real user-facing AIRI window because the process also owns hidden utility windows.
// The second-instance handler should restore the main UI instead of accidentally surfacing internals.
let userFacingMainWindow: BrowserWindow | undefined
const shouldStartMainProcess = installSingleInstanceGuard({ app, getWindow: () => userFacingMainWindow })
if (shouldStartMainProcess) {
initScreenCaptureForMain()
}
let fileLogger: FileLoggerHandle = nullFileLoggerHandle
let skipFileLogging = false
app.whenReady().then(async () => {
if (!shouldStartMainProcess) {
return
}
// Initialize file logger and register the hook
fileLogger = await setupFileLogger()
@@ -196,7 +210,12 @@ app.whenReady().then(async () => {
const mainWindow = injeca.provide('windows:main', {
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
build: async ({ dependsOn }) => setupMainWindow({
...dependsOn,
onWindowCreated: (window) => {
userFacingMainWindow = window
},
}),
})
const captionWindow = injeca.provide('windows:caption', {