fix(stage-tamagotchi): prevent duplicate desktop instances (#1815)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user