Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/stage-tamagotchi/src/main/libs/electron/location.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { EventEmitter } from 'node:events'

import { describe, expect, it, vi } from 'vitest'

import { withHashRoute } from './location'
import { load, withHashRoute } from './location'

vi.mock(import('@electron-toolkit/utils'), () => {
return {
Expand All @@ -10,6 +12,30 @@ vi.mock(import('@electron-toolkit/utils'), () => {
}
})

describe('load', () => {
// https://github.com/moeru-ai/airi/pull/2278#discussion_r3776743822
// ROOT CAUSE:
//
// The load helper removed every did-start-navigation listener after the initial load.
// This also removed the window service listener that restores mouse input before reloads.
//
// We fixed this by preserving listeners that the load helper does not own.
it('preserves navigation lifecycle listeners after the initial load', async () => {
const webContents = new EventEmitter()
const navigationHandler = vi.fn()
webContents.on('did-start-navigation', navigationHandler)
const window = {
loadURL: vi.fn().mockResolvedValue(undefined),
webContents,
}

await load(window as never, 'https://example.com')
webContents.emit('did-start-navigation')

expect(navigationHandler).toHaveBeenCalledOnce()
})
})

describe('withHashRoute', () => {
it('should use string url construct URL with hash route correctly', () => {
const result = withHashRoute('http://localhost:5173', '/test/inner-test')
Expand Down
3 changes: 0 additions & 3 deletions apps/stage-tamagotchi/src/main/libs/electron/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,6 @@ export async function load(window: BrowserWindow, url: string | { url: string, o

throw error
}
finally {
window.webContents.removeAllListeners('did-start-navigation')
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

interface MockContext {
emit: ReturnType<typeof vi.fn>
invokeHandlers: Map<string, (payload: unknown, options?: unknown) => unknown>
}

interface MockEmitter {
emit: (event: string, ...args: unknown[]) => void
on: ReturnType<typeof vi.fn>
}

function createEmitter(): MockEmitter {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>()
return {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const eventHandlers = handlers.get(event) ?? []
eventHandlers.push(handler)
handlers.set(event, eventHandlers)
}),
emit(event, ...args) {
for (const handler of handlers.get(event) ?? [])
handler(...args)
},
}
}

function createMockContext(): MockContext {
return {
emit: vi.fn(),
invokeHandlers: new Map(),
}
}

async function setupWindowService(options: { manageWindowInteractivity?: boolean } = {}) {
const rendererLoop = {
start: vi.fn(),
stop: vi.fn(),
}

vi.doMock('@moeru/eventa', async (importOriginal) => {
const actual = await importOriginal<typeof import('@moeru/eventa')>()
return {
...actual,
defineInvokeHandler: (
context: MockContext,
eventa: { sendEvent: { id: string } },
handler: (payload: unknown, options?: unknown) => unknown,
) => {
context.invokeHandlers.set(eventa.sendEvent.id.replace(/-send$/, ''), handler)
},
}
})

vi.doMock('@proj-airi/electron-eventa', () => ({
bounds: { id: 'bounds' },
startLoopGetBounds: { sendEvent: { id: 'start-loop-send' } },
}))

vi.doMock('@proj-airi/electron-vueuse/main', () => ({
createRendererLoop: () => rendererLoop,
safeClose: vi.fn(),
}))

vi.doMock('../../../shared/eventa', () => ({
electron: {
window: {
getBounds: { sendEvent: { id: 'get-bounds-send' } },
resize: { sendEvent: { id: 'resize-send' } },
setBackgroundMaterial: { sendEvent: { id: 'set-background-material-send' } },
setBounds: { sendEvent: { id: 'set-bounds-send' } },
setIgnoreMouseEvents: { sendEvent: { id: 'set-ignore-mouse-events-send' } },
setVibrancy: { sendEvent: { id: 'set-vibrancy-send' } },
},
},
electronGetWindowLifecycleState: { sendEvent: { id: 'get-lifecycle-send' } },
electronWindowClose: { sendEvent: { id: 'close-send' } },
electronWindowLifecycleChanged: { id: 'lifecycle-changed' },
electronWindowSetAlwaysOnTop: { sendEvent: { id: 'set-always-on-top-send' } },
}))

vi.doMock('../../libs/bootkit/lifecycle', () => ({
onAppBeforeQuit: vi.fn(),
onAppWindowAllClosed: vi.fn(),
}))

vi.doMock('../../windows/shared/window', () => ({
resizeWindowByDelta: vi.fn(),
}))

vi.doMock('std-env', () => ({ isWindows: process.platform === 'win32' }))

const windowEvents = createEmitter()
const webContentsEvents = createEmitter()
const setIgnoreMouseEvents = vi.fn()
const window = {
...windowEvents,
getBounds: vi.fn(() => ({ height: 600, width: 800, x: 0, y: 0 })),
isFocused: vi.fn(() => true),
isMinimized: vi.fn(() => false),
isVisible: vi.fn(() => true),
setAlwaysOnTop: vi.fn(),
setBackgroundMaterial: vi.fn(),
setBounds: vi.fn(),
setIgnoreMouseEvents,
setVibrancy: vi.fn(),
webContents: {
...webContentsEvents,
id: 42,
},
}
const context = createMockContext()
const { createWindowService } = await import('./window')
createWindowService({
context: context as never,
window: window as never,
manageWindowInteractivity: options.manageWindowInteractivity,
})

return {
context,
setIgnoreMouseEvents,
webContents: window.webContents,
}
}

function sameWindowOptions() {
return {
raw: {
ipcMainEvent: {
sender: { id: 42 },
},
},
}
}

describe('window interactivity recovery', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
vi.clearAllMocks()
})

// https://github.com/moeru-ai/airi/issues/2160
it('starts each managed window with mouse input enabled (Issue #2160)', async () => {
// ROOT CAUSE:
//
// The main process does not establish a fail-open input state when it registers a window.
// A recreated native window can therefore depend on stale renderer state.
// The fix must make the main process set the initial input state.
const service = await setupWindowService()

expect(service.setIgnoreMouseEvents).toHaveBeenCalledWith(false)
})

it('preserves input isolation for a visual-only overlay', async () => {
const service = await setupWindowService({ manageWindowInteractivity: false })

expect(service.setIgnoreMouseEvents).not.toHaveBeenCalled()
})

// https://github.com/moeru-ai/airi/issues/2160
it('restores mouse input when the renderer process exits (Issue #2160)', async () => {
// ROOT CAUSE:
//
// The renderer owns the native click-through state.
// A renderer exit stops the code that can call `setIgnoreMouseEvents(false)`.
// The fix must make the main process restore input after a renderer exit.
const service = await setupWindowService()
const handler = service.context.invokeHandlers.get('set-ignore-mouse-events')

expect(handler).toBeDefined()
handler!([true, { forward: true }], sameWindowOptions())
service.webContents.emit('render-process-gone', {}, { reason: 'crashed' })

expect(service.setIgnoreMouseEvents).toHaveBeenLastCalledWith(false)
})

// https://github.com/moeru-ai/airi/issues/2160
it('restores mouse input when the renderer becomes unresponsive (Issue #2160)', async () => {
// ROOT CAUSE:
//
// An unresponsive renderer cannot send the request that restores native mouse input.
// The fix must let the main process restore input without renderer cooperation.
const service = await setupWindowService()
const handler = service.context.invokeHandlers.get('set-ignore-mouse-events')

expect(handler).toBeDefined()
handler!([true, { forward: true }], sameWindowOptions())
service.webContents.emit('unresponsive')

expect(service.setIgnoreMouseEvents).toHaveBeenLastCalledWith(false)
})

// https://github.com/moeru-ai/airi/issues/2160
it('restores mouse input before renderer navigation (Issue #2160)', async () => {
// ROOT CAUSE:
//
// Renderer navigation disposes the code that owns the current click-through state.
// The fix must restore input before the old renderer lifecycle ends.
const service = await setupWindowService()
const handler = service.context.invokeHandlers.get('set-ignore-mouse-events')

expect(handler).toBeDefined()
handler!([true, { forward: true }], sameWindowOptions())
service.webContents.emit('did-start-navigation', {}, 'file:///app/index.html', false, true)

expect(service.setIgnoreMouseEvents).toHaveBeenLastCalledWith(false)
})

// https://github.com/moeru-ai/airi/issues/2160
it('restores mouse input when a click-through lease expires (Issue #2160)', async () => {
// ROOT CAUSE:
//
// A click-through request has no lifetime and remains active until another renderer request changes it.
// The fix must give this transient state a bounded lease that fails open.
const service = await setupWindowService()
const handler = service.context.invokeHandlers.get('set-ignore-mouse-events')

expect(handler).toBeDefined()
handler!([true, { forward: true }], sameWindowOptions())
await vi.advanceTimersByTimeAsync(2001)

expect(service.setIgnoreMouseEvents).toHaveBeenLastCalledWith(false)
})
})
57 changes: 55 additions & 2 deletions apps/stage-tamagotchi/src/main/services/electron/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,51 @@ import {
import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle'
import { resizeWindowByDelta, setWindowAlwaysOnTop } from '../../windows/shared/window'

export function createWindowService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
// NOTICE:
// This duration and the renderer's `mouseInputLeaseRenewInterval` (in
// `use-window-interactivity-lease.ts`) form one cross-process timing
// contract: the renderer must renew strictly more often than this window
// expires, or click-through can lapse between renewals and the window
// starts intercepting clicks it should be passing through. Keep this value
// at least double the renderer's renew interval when changing either one.
const mouseInputLeaseDuration = 2000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the lease timing relationship

mouseInputLeaseDuration and the renderer's mouseInputLeaseRenewInterval form one cross-process timing contract, but neither constant documents that renewals must precede expiration. If either value changes independently, click-through can expire between heartbeats and make the window intercept clicks. Document the relationship beside both constants, or define it once in a shared contract.

AGENTS.md reference: AGENTS.md:L317-L319

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 618c25a: documented the cross-process timing contract at both constants (mouseInputLeaseDuration in window.ts, mouseInputLeaseRenewInterval in the composable), noting renewal must happen strictly more often than expiration.


export function createWindowService(params: {
context: ReturnType<typeof createContext>['context']
window: BrowserWindow
manageWindowInteractivity?: boolean
}) {
const manageWindowInteractivity = params.manageWindowInteractivity ?? true
let mouseInputLeaseTimeout: ReturnType<typeof setTimeout> | undefined

function clearMouseInputLease() {
clearTimeout(mouseInputLeaseTimeout)
mouseInputLeaseTimeout = undefined
}

function restoreMouseInput() {
clearMouseInputLease()
params.window.setIgnoreMouseEvents(false)
}

function setIgnoreMouseEvents(ignore: boolean, options: { forward: boolean }) {
clearMouseInputLease()
params.window.setIgnoreMouseEvents(ignore, options)

if (!ignore)
return

// NOTICE:
// The renderer renews this lease while it needs click-through behavior.
// A renderer failure stops renewal, and the main process restores mouse input.
// Source/context: https://github.com/moeru-ai/airi/issues/2160
// Removal condition: Electron automatically resets click-through state after renderer failure.
mouseInputLeaseTimeout = setTimeout(restoreMouseInput, mouseInputLeaseDuration)
}

if (manageWindowInteractivity)
restoreMouseInput()

function getWindowLifecycleState(reason: ElectronWindowLifecycleState['reason']): ElectronWindowLifecycleState {
return {
focused: params.window.isFocused(),
Expand Down Expand Up @@ -54,6 +98,12 @@ export function createWindowService(params: { context: ReturnType<typeof createC
params.window.on('restore', () => emitWindowLifecycle('restore'))
params.window.on('focus', () => emitWindowLifecycle('focus'))
params.window.on('blur', () => emitWindowLifecycle('blur'))
if (manageWindowInteractivity) {
params.window.on('closed', clearMouseInputLease)
params.window.webContents.on('render-process-gone', restoreMouseInput)
params.window.webContents.on('unresponsive', restoreMouseInput)
params.window.webContents.on('did-start-navigation', restoreMouseInput)
}

defineInvokeHandler(params.context, electron.window.getBounds, (_, options) => {
if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) {
Expand All @@ -76,7 +126,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC

defineInvokeHandler(params.context, electron.window.setIgnoreMouseEvents, (opts, options) => {
if (opts && params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) {
params.window.setIgnoreMouseEvents(...opts)
if (manageWindowInteractivity)
setIgnoreMouseEvents(...opts)
else
params.window.setIgnoreMouseEvents(...opts)
}
})

Expand Down
Loading
Loading