-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[1/3] feat(desktop): add desktop observation and overlay baseline #1647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
a2771dc
feat: recut desktop grounding v1 into a clean review slice
a4525b9
[autofix.ci] apply automated fixes
autofix-ci[bot] 089add5
fix(desktop): harden grounding review blockers
261a4f3
[autofix.ci] apply automated fixes
autofix-ci[bot] 9766d7f
fix(desktop): offset iframe semantic targets and allow bridge overrides
6aff067
[autofix.ci] apply automated fixes
autofix-ci[bot] 3f7bacf
fix(desktop): trim grounding screenshot state and stabilize iframe of…
031aa09
[autofix.ci] apply automated fixes
autofix-ci[bot] 6856000
fix(desktop): avoid piling up timed-out overlay polls
8f8f215
fix(desktop): export mcp bridge from stage-ui stores
f9e5737
[autofix.ci] apply automated fixes
autofix-ci[bot] 4e4c702
fix(desktop): restore overlay mcp wiring on current runtime
bd57750
[autofix.ci] apply automated fixes
autofix-ci[bot] 2cde0c2
fix(desktop): recover overlay polling after timeouts
41b60bc
fix(desktop): lease timed-out overlay poll recovery slots
670ab98
Merge branch 'main' into codex/desktop-v1-clean
3361559784 76846d7
Merge branch 'main' into codex/desktop-v1-clean
3361559784 7673bdd
[autofix.ci] apply automated fixes
autofix-ci[bot] 0c7c2f1
Merge branch 'main' into codex/desktop-v1-clean
3361559784 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
148 changes: 148 additions & 0 deletions
148
apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| /** | ||
| * Desktop Grounding Overlay — transparent always-on-top window | ||
| * | ||
| * Renders: | ||
| * - Ghost pointer dot at the snap-resolved click position | ||
| * - Bounding box around the matched target candidate | ||
| * - Source label + confidence badge | ||
| * - Stale flags | ||
| * | ||
| * Gated by AIRI_DESKTOP_OVERLAY=1 environment variable. | ||
| * When disabled, this module is a no-op. | ||
| * | ||
| * Data flow (v1): | ||
| * - The overlay renderer polls `computer_use::desktop_get_state` via the MCP bridge | ||
| * - No IPC push from main process to renderer | ||
| * - No Eventa channels or server push | ||
| * | ||
| * The overlay is click-through (setIgnoreMouseEvents) so it never | ||
| * intercepts real user or OS-level click events. | ||
| */ | ||
|
|
||
| import type { I18n } from '../../libs/i18n' | ||
| import type { ServerChannel } from '../../services/airi/channel-server' | ||
| import type { McpStdioManager } from '../../services/airi/mcp-servers' | ||
|
|
||
| import { join, resolve } from 'node:path' | ||
|
|
||
| import { BrowserWindow, screen } from 'electron' | ||
|
|
||
| import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' | ||
| import { setupDesktopOverlayElectronInvokes } from './rpc/index.electron' | ||
|
|
||
| /** Whether the desktop overlay feature is enabled */ | ||
| export function isDesktopOverlayEnabled(): boolean { | ||
| return process.env.AIRI_DESKTOP_OVERLAY === '1' | ||
|
Check failure on line 35 in apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts
|
||
| } | ||
|
|
||
| let overlayWindow: BrowserWindow | null = null | ||
|
|
||
| /** | ||
| * Create the transparent overlay window covering the full primary display. | ||
| * The window is: | ||
| * - Always on top (screen level) | ||
| * - Click-through (ignoreMouseEvents) | ||
| * - Transparent and frameless | ||
| * - Not shown in taskbar / dock | ||
| * | ||
| * Returns null if AIRI_DESKTOP_OVERLAY is not set. | ||
| */ | ||
| export async function setupDesktopOverlayWindow(params: { | ||
| mcpStdioManager: McpStdioManager | ||
| serverChannel: ServerChannel | ||
| i18n: I18n | ||
| }): Promise<BrowserWindow | null> { | ||
| if (!isDesktopOverlayEnabled()) { | ||
| return null | ||
| } | ||
|
|
||
| // Use primary display bounds (not just size) — the origin may be non-zero | ||
| // when multiple displays are arranged in macOS Display Preferences. | ||
| const primaryDisplay = screen.getPrimaryDisplay() | ||
| const { x, y, width, height } = primaryDisplay.bounds | ||
|
|
||
| overlayWindow = new BrowserWindow({ | ||
| title: 'AIRI Desktop Overlay', | ||
| width, | ||
| height, | ||
| x, | ||
| y, | ||
| show: false, | ||
| frame: false, | ||
| transparent: true, | ||
| alwaysOnTop: true, | ||
| skipTaskbar: true, | ||
| hasShadow: false, | ||
| // Round corners off for pixel-accurate overlay | ||
| roundedCorners: false, | ||
| // Prevent the overlay from stealing focus | ||
| focusable: false, | ||
| webPreferences: { | ||
| preload: join(getElectronMainDirname(), '../preload/index.mjs'), | ||
| sandbox: false, | ||
| // Disable background throttling so animations stay smooth | ||
| backgroundThrottling: false, | ||
| }, | ||
| }) | ||
|
|
||
| // Make click-through: all mouse events pass through to the desktop | ||
| overlayWindow.setIgnoreMouseEvents(true, { forward: true }) | ||
|
|
||
| // Set to screen level (above all other windows) | ||
| overlayWindow.setAlwaysOnTop(true, 'screen-saver') | ||
|
|
||
| // Prevent the window from appearing in screenshots/recordings if possible | ||
| overlayWindow.setContentProtection(true) | ||
|
|
||
| // Hide from Mission Control / Exposé on macOS | ||
| overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) | ||
|
|
||
| overlayWindow.on('ready-to-show', () => { | ||
| overlayWindow?.show() | ||
| }) | ||
|
|
||
| overlayWindow.on('closed', () => { | ||
| overlayWindow = null | ||
| }) | ||
|
|
||
| // NOTICE: Wire eventa RPC BEFORE loading the renderer page. | ||
| // The overlay's onMounted fires during load() and immediately starts | ||
| // polling via callTool. If the handlers aren't registered yet, the | ||
| // first eventa invoke hangs forever (no response dispatched back to | ||
| // this window), and all subsequent poll cycles never fire because | ||
| // the poll loop awaits each call sequentially. | ||
| await setupDesktopOverlayElectronInvokes({ | ||
| window: overlayWindow, | ||
| mcpStdioManager: params.mcpStdioManager, | ||
| serverChannel: params.serverChannel, | ||
| i18n: params.i18n, | ||
| }) | ||
|
|
||
| // Load the overlay renderer page | ||
| await load( | ||
| overlayWindow, | ||
| withHashRoute( | ||
| baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), | ||
| '/desktop-overlay', | ||
| ), | ||
| ) | ||
|
|
||
| return overlayWindow | ||
| } | ||
|
|
||
| /** | ||
| * Get the current overlay window instance (if active). | ||
| */ | ||
| export function getDesktopOverlayWindow(): BrowserWindow | null { | ||
| return overlayWindow | ||
| } | ||
|
|
||
| /** | ||
| * Tear down the overlay window. | ||
| */ | ||
| export function destroyDesktopOverlay(): void { | ||
| if (overlayWindow && !overlayWindow.isDestroyed()) { | ||
| overlayWindow.close() | ||
| overlayWindow = null | ||
| } | ||
| } | ||
39 changes: 39 additions & 0 deletions
39
apps/stage-tamagotchi/src/main/windows/desktop-overlay/rpc/index.electron.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| /** | ||
| * Desktop Overlay Window — Electron RPC bootstrap | ||
| * | ||
| * Minimal eventa context setup for the overlay BrowserWindow. | ||
| * Only registers base window services and MCP tool services — | ||
| * the overlay only needs callTool/listTools for polling | ||
| * `computer_use::desktop_get_state`. | ||
| * | ||
| * Follows the same pattern as main/chat/settings window RPC setups. | ||
| */ | ||
|
|
||
| import type { BrowserWindow } from 'electron' | ||
|
|
||
| import type { I18n } from '../../../libs/i18n' | ||
| import type { ServerChannel } from '../../../services/airi/channel-server' | ||
| import type { McpStdioManager } from '../../../services/airi/mcp-servers' | ||
|
|
||
| import { createContext } from '@moeru/eventa/adapters/electron/main' | ||
| import { ipcMain } from 'electron' | ||
|
|
||
| import { createMcpServersService } from '../../../services/airi/mcp-servers' | ||
| import { setupBaseWindowElectronInvokes } from '../../shared/window' | ||
|
|
||
| export async function setupDesktopOverlayElectronInvokes(params: { | ||
| window: BrowserWindow | ||
| mcpStdioManager: McpStdioManager | ||
| serverChannel: ServerChannel | ||
| i18n: I18n | ||
| }) { | ||
| // TODO: once we refactored eventa to support window-namespaced contexts, | ||
| // we can remove the setMaxListeners call below since eventa will be able to dispatch and | ||
| // manage events within eventa's context system. | ||
| ipcMain.setMaxListeners(0) | ||
|
|
||
| const { context } = createContext(ipcMain, params.window) | ||
|
|
||
| await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) | ||
| createMcpServersService({ context, manager: params.mcpStdioManager }) | ||
| } |
120 changes: 120 additions & 0 deletions
120
apps/stage-tamagotchi/src/renderer/pages/desktop-overlay-coordinates.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { | ||
| pointInOverlay, | ||
| rectIntersectsOverlay, | ||
| screenRectToLocal, | ||
| screenToLocal, | ||
| } from './desktop-overlay-coordinates' | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // screenToLocal | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('screenToLocal', () => { | ||
| it('subtracts overlay origin from screen point', () => { | ||
| const result = screenToLocal({ x: 500, y: -800 }, { x: 0, y: -1080 }) | ||
| expect(result).toEqual({ x: 500, y: 280 }) | ||
| }) | ||
|
|
||
| it('is identity when overlay origin is (0,0)', () => { | ||
| const result = screenToLocal({ x: 100, y: 200 }, { x: 0, y: 0 }) | ||
| expect(result).toEqual({ x: 100, y: 200 }) | ||
| }) | ||
|
|
||
| it('handles negative overlay origin', () => { | ||
| const result = screenToLocal({ x: 441, y: -1037 }, { x: 0, y: -1080 }) | ||
| expect(result).toEqual({ x: 441, y: 43 }) | ||
| }) | ||
| }) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // screenRectToLocal | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('screenRectToLocal', () => { | ||
| it('shifts rect origin, preserves size', () => { | ||
| const result = screenRectToLocal( | ||
| { x: 100, y: -1000, width: 80, height: 30 }, | ||
| { x: 0, y: -1080 }, | ||
| ) | ||
| expect(result).toEqual({ x: 100, y: 80, width: 80, height: 30 }) | ||
| }) | ||
|
|
||
| it('is identity when overlay origin is (0,0)', () => { | ||
| const rect = { x: 50, y: 100, width: 200, height: 150 } | ||
| const result = screenRectToLocal(rect, { x: 0, y: 0 }) | ||
| expect(result).toEqual(rect) | ||
| }) | ||
| }) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // rectIntersectsOverlay | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('rectIntersectsOverlay', () => { | ||
| const overlay = { x: 0, y: -1080, width: 1440, height: 900 } | ||
|
|
||
| it('returns true for rect fully inside overlay', () => { | ||
| expect(rectIntersectsOverlay( | ||
| { x: 100, y: -1000, width: 80, height: 30 }, | ||
| overlay, | ||
| )).toBe(true) | ||
| }) | ||
|
|
||
| it('returns true for rect partially overlapping', () => { | ||
| expect(rectIntersectsOverlay( | ||
| { x: 1400, y: -1080, width: 100, height: 50 }, | ||
| overlay, | ||
| )).toBe(true) | ||
| }) | ||
|
|
||
| it('returns false for rect entirely above overlay', () => { | ||
| expect(rectIntersectsOverlay( | ||
| { x: 100, y: -2000, width: 80, height: 30 }, | ||
| overlay, | ||
| )).toBe(false) | ||
| }) | ||
|
|
||
| it('returns false for rect entirely below overlay', () => { | ||
| expect(rectIntersectsOverlay( | ||
| { x: 100, y: 0, width: 80, height: 30 }, | ||
| overlay, | ||
| )).toBe(false) | ||
| }) | ||
|
|
||
| it('returns false for rect entirely to the right', () => { | ||
| expect(rectIntersectsOverlay( | ||
| { x: 1500, y: -500, width: 80, height: 30 }, | ||
| overlay, | ||
| )).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // pointInOverlay | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('pointInOverlay', () => { | ||
| const overlay = { x: 0, y: -1080, width: 1440, height: 900 } | ||
|
|
||
| it('returns true for point inside', () => { | ||
| expect(pointInOverlay({ x: 720, y: -540 }, overlay)).toBe(true) | ||
| }) | ||
|
|
||
| it('returns true for point at top-left corner', () => { | ||
| expect(pointInOverlay({ x: 0, y: -1080 }, overlay)).toBe(true) | ||
| }) | ||
|
|
||
| it('returns false for point outside (below)', () => { | ||
| expect(pointInOverlay({ x: 720, y: 0 }, overlay)).toBe(false) | ||
| }) | ||
|
|
||
| it('returns false for point outside (above)', () => { | ||
| expect(pointInOverlay({ x: 720, y: -1200 }, overlay)).toBe(false) | ||
| }) | ||
|
|
||
| it('returns false for point outside (right)', () => { | ||
| expect(pointInOverlay({ x: 1500, y: -540 }, overlay)).toBe(false) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
AIRI_DESKTOP_OVERLAY=1, this new block callsinjeca.invokewithcallback: noop, butnoopis not imported or defined insrc/main/index.ts. In that configuration, startup throws aReferenceErrorbefore initialization completes, so the overlay feature path crashes instead of booting. Please importnoop(or replace it with an inline no-op callback) in this file.Useful? React with 👍 / 👎.