From c0ced4ed17e450ffa8e1e7dae58231089a6a64dc Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Tue, 19 May 2026 19:13:20 +0800 Subject: [PATCH] fix(stage-tamagotchi): prevent duplicate desktop instances (#1815) --- .../src/main/app/single-instance.test.ts | 78 +++++++++++++++++++ .../src/main/app/single-instance.ts | 57 ++++++++++++++ apps/stage-tamagotchi/src/main/index.ts | 23 +++++- 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/app/single-instance.test.ts create mode 100644 apps/stage-tamagotchi/src/main/app/single-instance.ts diff --git a/apps/stage-tamagotchi/src/main/app/single-instance.test.ts b/apps/stage-tamagotchi/src/main/app/single-instance.test.ts new file mode 100644 index 000000000..24097c682 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/app/single-instance.test.ts @@ -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 + quit: ReturnType + requestSingleInstanceLock: ReturnType +} diff --git a/apps/stage-tamagotchi/src/main/app/single-instance.ts b/apps/stage-tamagotchi/src/main/app/single-instance.ts new file mode 100644 index 000000000..b0bcd56cf --- /dev/null +++ b/apps/stage-tamagotchi/src/main/app/single-instance.ts @@ -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 +} diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 0310a131c..e87403f59 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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', {