From 9560a26fe24170274442ad53d89cab7e5fe251e1 Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:26:51 +0800 Subject: [PATCH] feat(stage-tamagotchi): reset to center (#1978) Authored-by-agent: Codex --- apps/stage-tamagotchi/src/main/index.ts | 6 +- .../main/windows/main/rpc/index.electron.ts | 4 +- .../src/main/windows/settings/index.ts | 2 + .../windows/settings/rpc/index.electron.ts | 4 + .../src/main/windows/shared/display.test.ts | 109 +++++++++++++++++- .../src/main/windows/shared/display.ts | 56 +++++++++ .../stage-islands/controls-island/index.vue | 18 +++ .../data/components/desktop-reset-section.vue | 25 +++- .../src/shared/eventa/index.ts | 2 + packages/i18n/src/locales/en/settings.yaml | 2 + .../i18n/src/locales/en/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/es/settings.yaml | 2 + .../i18n/src/locales/es/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/fr/settings.yaml | 2 + .../i18n/src/locales/fr/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/ja/settings.yaml | 2 + .../i18n/src/locales/ja/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/ko/settings.yaml | 2 + .../i18n/src/locales/ko/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/ru/settings.yaml | 2 + .../i18n/src/locales/ru/tamagotchi/stage.yaml | 1 + packages/i18n/src/locales/vi/settings.yaml | 2 + .../i18n/src/locales/vi/tamagotchi/stage.yaml | 1 + .../i18n/src/locales/zh-Hans/settings.yaml | 2 + .../src/locales/zh-Hans/tamagotchi/stage.yaml | 1 + .../i18n/src/locales/zh-Hant/settings.yaml | 2 + .../src/locales/zh-Hant/tamagotchi/stage.yaml | 1 + 27 files changed, 249 insertions(+), 4 deletions(-) diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index a575cdd41..2eaa6db30 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -214,7 +214,11 @@ app.whenReady().then(async () => { const settingsWindow = injeca.provide('windows:settings', { dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut, spotlightWindow }, - build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn), + build: async ({ dependsOn }) => + setupSettingsWindowReusableFunc({ + ...dependsOn, + getMainWindow: () => userFacingMainWindow, + }), }) const mainWindow = injeca.provide('windows:main', { diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 20c2501b7..3a7370d8f 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -15,7 +15,7 @@ import { defineInvokeHandler } from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' -import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' +import { electronCenterMainWindow, electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' import { createAuthService } from '../../../services/airi/auth' import { createGodotStageService } from '../../../services/airi/godot-stage' import { createMcpServersService } from '../../../services/airi/mcp-servers' @@ -23,6 +23,7 @@ import { createOnboardingService } from '../../../services/airi/onboarding' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' import { toggleWindowShow } from '../../shared' +import { centerWindowOnDisplay } from '../../shared/display' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupMainWindowElectronInvokes(params: { @@ -54,6 +55,7 @@ export async function setupMainWindowElectronInvokes(params: { createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager }) + defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window)) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow())) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 2b877433f..8a7fe5e3e 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -31,6 +31,7 @@ export function setupSettingsWindowReusableFunc(params: { widgetsManager: WidgetsWindowManager autoUpdater: AutoUpdater devtoolsWindow: DevtoolsWindowManager + getMainWindow?: () => BrowserWindow | undefined onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel godotStageManager: GodotStageManager @@ -73,6 +74,7 @@ export function setupSettingsWindowReusableFunc(params: { widgetsManager: params.widgetsManager, autoUpdater: params.autoUpdater, devtoolsWindow: params.devtoolsWindow, + getMainWindow: params.getMainWindow, serverChannel: params.serverChannel, godotStageManager: params.godotStageManager, mcpStdioManager: params.mcpStdioManager, diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index f125c81dc..66a9d04aa 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -16,6 +16,7 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { + electronCenterMainWindow, electronOpenDevtoolsWindow, electronOpenSettingsDevtools, electronSpotlightShortcutGet, @@ -26,6 +27,7 @@ import { createGodotStageService } from '../../../services/airi/godot-stage' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' +import { centerWindowOnDisplay } from '../../shared/display' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupSettingsWindowInvokes(params: { @@ -33,6 +35,7 @@ export async function setupSettingsWindowInvokes(params: { widgetsManager: WidgetsWindowManager autoUpdater: AutoUpdater devtoolsWindow: DevtoolsWindowManager + getMainWindow?: () => BrowserWindow | undefined serverChannel: ServerChannel godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager @@ -59,6 +62,7 @@ export async function setupSettingsWindowInvokes(params: { // Register the global shortcut service for the settings window. params.globalShortcut.registerWindow({ context, window: params.settingsWindow }) + defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.getMainWindow?.())) defineInvokeHandler(context, electronSpotlightShortcutGet, () => params.spotlightWindow.getShortcutAccelerator()) defineInvokeHandler(context, electronSpotlightShortcutSet, (payload) => { if (payload?.accelerator === undefined) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts index 6da6145d3..81b7e833d 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.test.ts @@ -1,8 +1,16 @@ import type { Rectangle } from 'electron' +import { screen } from 'electron' import { describe, expect, it, vi } from 'vitest' -import { computeResizedBoundsAnchoredToDominantDisplay, heightFrom, mapForBreakpoints, widthFrom } from './display' +import { + centerWindowOnDisplay, + computeCenteredWindowBounds, + computeResizedBoundsAnchoredToDominantDisplay, + heightFrom, + mapForBreakpoints, + widthFrom, +} from './display' // NOTICE: // Mocking 'electron' is needed to prevent Vitest from attempting to resolve/load the real Electron binary during tests. @@ -132,3 +140,102 @@ describe('computeResizedBoundsAnchoredToDominantDisplay', () => { expect(bounds.height).toBe(600) }) }) + +/** + * @example + * computeCenteredWindowBounds({ displayWorkArea, windowBounds }) + */ +describe('computeCenteredWindowBounds', () => { + /** + * @example + * A 450x600 window is centered without changing its size. + */ + it('preserves the window size and centers it inside the display work area', () => { + const result = computeCenteredWindowBounds({ + displayWorkArea: { x: 0, y: 25, width: 1440, height: 875 }, + windowBounds: { x: 1200, y: 700, width: 450, height: 600 }, + }) + + expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) + }) + + /** + * @example + * A display above and left of the primary screen keeps negative coordinates. + */ + it('supports display work areas with negative origins', () => { + const result = computeCenteredWindowBounds({ + displayWorkArea: { x: -1920, y: -1080, width: 1920, height: 1055 }, + windowBounds: { x: -2300, y: -1300, width: 500, height: 620 }, + }) + + expect(result).toEqual({ x: -1210, y: -863, width: 500, height: 620 }) + }) + + /** + * @example + * An oversized window starts at the work-area origin instead of moving farther off-screen. + */ + it('keeps oversized windows anchored inside the display work area origin', () => { + const result = computeCenteredWindowBounds({ + displayWorkArea: { x: 120, y: 45, width: 800, height: 500 }, + windowBounds: { x: -2000, y: -900, width: 1000, height: 640 }, + }) + + expect(result).toEqual({ x: 120, y: 45, width: 1000, height: 640 }) + }) +}) + +/** + * @example + * centerWindowOnDisplay(window) + */ +describe('centerWindowOnDisplay', () => { + /** + * @example + * The recovered window receives centered bounds and becomes visible. + */ + it('sets centered bounds and shows the window', () => { + const windowBounds = { x: 1200, y: 700, width: 450, height: 600 } + const displayWorkArea = { x: 0, y: 25, width: 1440, height: 875 } + const setBounds = vi.fn() + const show = vi.fn() + vi.mocked(screen.getDisplayMatching).mockReturnValue({ workArea: displayWorkArea } as Electron.Display) + + const result = centerWindowOnDisplay({ + getBounds: () => windowBounds, + isDestroyed: () => false, + setBounds, + show, + }) + + expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 }) + expect(screen.getDisplayMatching).toHaveBeenCalledWith(windowBounds) + expect(setBounds).toHaveBeenCalledWith({ x: 495, y: 162, width: 450, height: 600 }) + expect(show).toHaveBeenCalledTimes(1) + }) + + /** + * @example + * A missing main window reports a stable domain error to the renderer. + */ + it('rejects recovery when the target window is unavailable', () => { + expect(() => centerWindowOnDisplay(undefined)).toThrowError('Main AIRI window is not available.') + }) + + /** + * @example + * A destroyed window is rejected before Electron bounds methods are called. + */ + it('rejects recovery when the target window was destroyed', () => { + const getBounds = vi.fn() + + expect(() => centerWindowOnDisplay({ + getBounds, + isDestroyed: () => true, + setBounds: vi.fn(), + show: vi.fn(), + })).toThrowError('Main AIRI window is not available.') + expect(getBounds).not.toHaveBeenCalled() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.ts index 4701f1865..4dbf6f4e8 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.ts @@ -9,6 +9,62 @@ export function currentDisplayBounds(window: BrowserWindow) { return nearbyDisplay.bounds } +/** + * Computes bounds that center a window inside an Electron display work area. + * + * Use when: + * - Recovering a desktop window that was moved outside the visible work area + * - Preserving the current window size while changing only its position + * + * Expects: + * - Both rectangles use Electron logical display coordinates + * - The display work area excludes menu bars, docks, and taskbars + * + * Returns: + * - Centered bounds that preserve the window width and height + */ +export function computeCenteredWindowBounds(options: { + displayWorkArea: Rectangle + windowBounds: Rectangle +}): Rectangle { + const centeredOffsetX = Math.floor((options.displayWorkArea.width - options.windowBounds.width) / 2) + const centeredOffsetY = Math.floor((options.displayWorkArea.height - options.windowBounds.height) / 2) + + return { + x: options.displayWorkArea.x + Math.max(0, centeredOffsetX), + y: options.displayWorkArea.y + Math.max(0, centeredOffsetY), + width: options.windowBounds.width, + height: options.windowBounds.height, + } +} + +/** + * Centers and reveals an Electron window on the display matching its current bounds. + * + * Use when: + * - A renderer requests recovery of an off-screen AIRI window + * - A hidden window must become visible after its position is restored + * + * Expects: + * - The window is alive and supports Electron's bounds APIs + * + * Returns: + * - The centered bounds applied to the window + */ +export function centerWindowOnDisplay(window: Pick | undefined): Rectangle { + if (!window || window.isDestroyed()) + throw new Error('Main AIRI window is not available.') + + const windowBounds = window.getBounds() + const displayWorkArea = screen.getDisplayMatching(windowBounds).workArea + const centeredBounds = computeCenteredWindowBounds({ displayWorkArea, windowBounds }) + + window.setBounds(centeredBounds) + window.show() + + return centeredBounds +} + export interface ResizableDisplayArea { /** Full display bounds used to decide which physical display owns most of a window. */ bounds: Rectangle diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index a9cc2bd02..093ade735 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -19,6 +19,7 @@ import IndicatorMicVolume from './indicator-mic-volume.vue' import { electron, electronAppQuit, + electronCenterMainWindow, electronOpenChat, electronOpenSettings, electronStartDraggingWindow, @@ -38,6 +39,7 @@ const openChat = useElectronEventaInvoke(electronOpenChat) const isLinux = useElectronEventaInvoke(electron.app.isLinux) const closeWindow = useElectronEventaInvoke(electronAppQuit) const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop) +const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow) const expanded = ref(false) const islandRef = ref() @@ -128,6 +130,13 @@ const startDraggingWindow = !isLinux() ? defineInvoke(context.value, electronSta function refreshWindow() { window.location.reload() } + +/** + * Requests the main process to move the AIRI desktop window back to screen center. + */ +function resetMainWindowPosition() { + centerMainWindow().catch(console.error) +} + + +
+ + + + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/data/components/desktop-reset-section.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/data/components/desktop-reset-section.vue index b0f4d7f54..fa21a8e73 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/data/components/desktop-reset-section.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/data/components/desktop-reset-section.vue @@ -1,18 +1,25 @@