diff --git a/desktop/package.json b/desktop/package.json index 5645869fa..d9451cad1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "main": "dist/main/main.js", "scripts": { "dev": "node scripts/run-dev.js", - "dev:main": "tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/main/pty-worker.js','dist/main/pty-worker.js')\" && electron .", + "dev:main": "tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/main/pty-worker.js','dist/main/pty-worker.js')\" && electron . --ozone-platform=x11", "dev:renderer": "vite", "build": "tsc && node -e \"require('fs').cpSync('src/main/pty-worker.js','dist/main/pty-worker.js')\" && node -e \"require('fs').mkdirSync('dist/renderer/data',{recursive:true})\" && node -e \"require('fs').cpSync('src/renderer/data/skill-registry.json','dist/renderer/data/skill-registry.json')\" && vite build && electron-builder", "test": "vitest", diff --git a/desktop/src/main/buddy-window-manager.ts b/desktop/src/main/buddy-window-manager.ts index 9be52f6b4..0e3e450b0 100644 --- a/desktop/src/main/buddy-window-manager.ts +++ b/desktop/src/main/buddy-window-manager.ts @@ -236,9 +236,10 @@ export class BuddyWindowManager { for (const leg of legs) { if (leg.win.isDestroyed()) continue; alive = true; - leg.win.setPosition( - Math.round(leg.from.x + (leg.to.x - leg.from.x) * ease), - Math.round(leg.from.y + (leg.to.y - leg.from.y) * ease), + this.place( + leg.win, + leg.from.x + (leg.to.x - leg.from.x) * ease, + leg.from.y + (leg.to.y - leg.from.y) * ease, ); } if (t >= 1 || !alive) { @@ -321,7 +322,7 @@ export class BuddyWindowManager { const mb = this.mascot.getBounds(); const d = screen.getDisplayMatching(mb) ?? screen.getPrimaryDisplay(); const flush = dockPosition(savedEdge, { x: mb.x, y: mb.y }, MASCOT_SIZE, d.workArea); - this.mascot.setPosition(Math.round(flush.x), Math.round(flush.y)); + this.place(this.mascot, flush.x, flush.y); // A buddy put away on an edge comes back put away — peeking is the // resting state at an edge now, not a timer's eventual destination. this.dockState = { mode: 'peeking', edge: savedEdge }; @@ -411,7 +412,7 @@ export class BuddyWindowManager { // position. this.chatOpenIntent = true; const layout = this.layoutFor(); - this.chat.setPosition(Math.round(layout.chat.x), Math.round(layout.chat.y)); + this.place(this.chat, layout.chat.x, layout.chat.y); this.chat.show(); this.chat.webContents.send(IPC_CHAT_STATE, { visible: true }); this.barVisibility.setChatOpen(true); @@ -475,6 +476,37 @@ export class BuddyWindowManager { getChatWindow(): BrowserWindow | null { return this.chat; } getBarWindow(): BrowserWindow | null { return this.bar; } + /** + * Move a buddy window, re-asserting its fixed size on Linux. + * + * WHY: on XWayland with fractional display scaling (e.g. KDE at 1.5×), + * Electron's setPosition() inflates a frameless window's size by a + * DIP↔physical rounding error on EVERY call. Probe-confirmed: a 334×490 + * window grew to 373×529 after 40 setPosition calls (setBounds with the fixed + * size pins it; setMinimumSize/setMaximumSize do NOT). moveMascot fires one + * setPosition per pointermove, so across a real drag the chat ballooned past + * the whole screen (measured 1851×1526) and the three windows drifted apart — + * this is the "chat keeps getting bigger / doesn't stay together" jank. + * Re-asserting the size via setBounds every move stops the accumulation. We + * only do this on Linux so Windows/macOS keep the cheaper setPosition path + * (its DWM cost is noted in moveMascot) where no inflation occurs. The size + * comes from the same constants main.ts builds the windows with, so + * re-asserting it is always exact. + */ + private place(win: BrowserWindow, x: number, y: number): void { + const rx = Math.round(x); + const ry = Math.round(y); + const size = + win === this.mascot ? MASCOT_SIZE : + win === this.chat ? CHAT_SIZE : + win === this.bar ? BAR_SIZE : null; + if (process.platform === 'linux' && size) { + win.setBounds({ x: rx, y: ry, width: size.width, height: size.height }); + } else { + win.setPosition(rx, ry); + } + } + /** * Place the mascot at an anchor-based target position from the renderer * (cursor screenX/Y minus the grab offset captured on pointerdown). Clamps @@ -518,7 +550,7 @@ export class BuddyWindowManager { this.closeChat(); // fades chat + bar out; chatOpenIntent flips false now this.dispatchDock({ type: 'drag-peek', edge: shove }); const pos = dockPosition(shove, clamped, MASCOT_SIZE, wa); - this.mascot.setPosition(Math.round(pos.x), Math.round(pos.y)); + this.place(this.mascot, pos.x, pos.y); return; } if (this.dockState.mode !== 'free') this.dispatchDock({ type: 'drag-start' }); @@ -540,7 +572,7 @@ export class BuddyWindowManager { if (edge) { this.dispatchDock({ type: 'drag-peek', edge }); const pos = dockPosition(edge, clamped, MASCOT_SIZE, wa); - this.mascot.setPosition(Math.round(pos.x), Math.round(pos.y)); + this.place(this.mascot, pos.x, pos.y); return; // flush against the edge; chat closed → no satellites to follow } this.dispatchDock({ type: 'drag-start' }); // in open space → free/idle @@ -551,7 +583,7 @@ export class BuddyWindowManager { // conversion failure" from Electron's native bridge. const newX = Math.round(clamped.x); const newY = Math.round(clamped.y); - this.mascot.setPosition(newX, newY); + this.place(this.mascot, newX, newY); // Move the chat by the SAME delta the mascot actually moved (not the // requested delta, which may have been clamped). Clamp the follow- // position to the chat's own display's workArea — the mascot may be @@ -571,7 +603,7 @@ export class BuddyWindowManager { const chatRaw = { x: cb.x + actualDx, y: cb.y + actualDy }; const chatDisplay = screen.getDisplayMatching({ ...chatRaw, ...CHAT_SIZE }) ?? screen.getPrimaryDisplay(); const chatClamped = clampToWorkArea(chatRaw, CHAT_SIZE, chatDisplay.workArea); - this.chat.setPosition(Math.round(chatClamped.x), Math.round(chatClamped.y)); + this.place(this.chat, chatClamped.x, chatClamped.y); } // Bar follows its own CSS visibility (not Electron isVisible() — the // window stays Electron-shown once created; reveals are CSS fades). @@ -579,7 +611,7 @@ export class BuddyWindowManager { // edge the bar needs to flip above automatically. if (this.bar && !this.bar.isDestroyed() && this.barCssVisible) { const pos = this.currentBarPosition(); - this.bar.setPosition(Math.round(pos.x), Math.round(pos.y)); + this.place(this.bar, pos.x, pos.y); } } @@ -601,7 +633,7 @@ export class BuddyWindowManager { } }); } else { - this.bar.setPosition(Math.round(pos.x), Math.round(pos.y)); + this.place(this.bar, pos.x, pos.y); } if (!this.bar.isVisible()) this.bar.showInactive(); } diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 325babed9..5ba3c788a 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -67,6 +67,63 @@ import { cleanupStaleDownloads } from './update-installer'; import { runAnalyticsOnLaunch } from './analytics-service'; import { loadConfigSync, setAppliedAtLaunch, setCachedGpu } from './performance-config'; +// Route Linux Wayland sessions through XWayland. *** EXPERIMENTAL — NOT +// PRODUCTION-READY *** (see the PR / investigation doc; shelved 2026-07-23). +// +// WHY: native Wayland forbids a client from positioning its own windows, and +// the buddy floater's entire model is main-process setPosition per drag frame. +// On Wayland every one of those calls is silently dropped — worse, getPosition() +// echoes back the value you asked for, so the app cannot even detect the +// failure. Probed against KWin 6.7.3 / Electron 41.0.3 on 2026-07-22 +// (docs/active/prototypes/2026-07-22-buddy-wayland-workbench/FINDINGS.md): +// +// native Wayland XWayland +// setPosition() no-op (reports success) works +// getCursorScreenPoint {0,0} real coords +// always-on-top KWin keepAbove=false KWin keepAbove=true +// transparency froze opaque 0/10 stable 10/10 +// +// The 2026-07-17 rejection of XWayland assumed it renders blurry at fractional +// scaling. That was wrong on THIS setup: KDE's [Xwayland] Scale makes KWin hand +// X11 clients native resolution, so Electron reports devicePixelRatio 1.5 and +// renders at full 2560x1600. (Sharpness is compositor-specific — it can still +// be blurry on GNOME / other fractional-scale setups. One of several reasons +// this stays experimental.) +// +// *** KNOWN GAP (verified 2026-07-23) — this appendSwitch DOES NOT WORK. *** +// app.commandLine.appendSwitch('ozone-platform', 'x11') is silently ineffective +// on Electron 41: the process still comes up on the native Wayland ozone +// backend (confirmed by the ui/ozone/platform/wayland/* init logs and KWin +// reporting the windows as native, surface!=null). ELECTRON_OZONE_PLATFORM_HINT +// was also ineffective. The ONLY mechanism that actually forced XWayland was a +// real argv flag on the electron binary: `electron . --ozone-platform=x11`. +// For dev that flag is injected by the `dev:main` npm script on this branch; +// production enablement would need the app to re-exec itself with the flag (or a +// launcher/.desktop entry that carries it) — NOT YET IMPLEMENTED. The block +// below is kept as intent + kill switch, but on its own it changes nothing. +// +// Kill switch: YOUCODED_OZONE=wayland forces the native backend back on. +if (process.platform === 'linux' && !app.isReady()) { + const ozoneOverride = process.env.YOUCODED_OZONE; + const isWaylandSession = + process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY; + if (ozoneOverride) { + app.commandLine.appendSwitch('ozone-platform', ozoneOverride); + } else if (isWaylandSession) { + app.commandLine.appendSwitch('ozone-platform', 'x11'); // see KNOWN GAP above + } + // GPU-crash workaround (verified 2026-07-23): under XWayland the GPU process + // SIGSEGVs (exit_code=139) inside bundled ANGLE at EGL_CreateWindowSurface on + // this machine's AMD/mesa stack — the default GL backend crashed 3× on every + // launch. YOUCODED_ANGLE=vulkan routed ANGLE through Vulkan (RADV) and + // eliminated the crash entirely (0 vs 3). This is machine-specific: other GPUs + // (Nvidia/Intel/older mesa) may need a different backend, so we do NOT default + // it — it must be passed explicitly (YOUCODED_ANGLE=vulkan|swiftshader|gl). + if (process.env.YOUCODED_ANGLE) { + app.commandLine.appendSwitch('use-angle', process.env.YOUCODED_ANGLE); + } +} + // macOS and Linux Electron apps may inherit a minimal PATH that's missing // common tool locations (Homebrew, nvm, Volta, pipx, cargo). macOS Finder/Dock // only provides /usr/bin:/bin:/usr/sbin:/sbin. Linux Snap/Flatpak/some DEs may diff --git a/desktop/tests/ipc-handlers.test.ts b/desktop/tests/ipc-handlers.test.ts index f09d644e2..53579d199 100644 --- a/desktop/tests/ipc-handlers.test.ts +++ b/desktop/tests/ipc-handlers.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('electron', () => ({ // whenReady must never resolve — otherwise main.ts runs its entire init chain // (createWindow, RemoteServer, SyncService, etc.) which hits unmocked APIs. - app: { isPackaged: false, getPath: vi.fn(() => '/tmp'), getVersion: vi.fn(() => '0.0.0-test'), whenReady: vi.fn(() => new Promise(() => {})), on: vi.fn(), quit: vi.fn(), setAppUserModelId: vi.fn(), commandLine: { appendSwitch: vi.fn() }, getGPUInfo: vi.fn(() => new Promise(() => {})) }, + app: { isPackaged: false, isReady: vi.fn(() => false), getPath: vi.fn(() => '/tmp'), getVersion: vi.fn(() => '0.0.0-test'), whenReady: vi.fn(() => new Promise(() => {})), on: vi.fn(), quit: vi.fn(), setAppUserModelId: vi.fn(), commandLine: { appendSwitch: vi.fn() }, getGPUInfo: vi.fn(() => new Promise(() => {})) }, ipcMain: { handle: vi.fn(), on: vi.fn() }, BrowserWindow: vi.fn(() => ({ loadURL: vi.fn(), on: vi.fn(), webContents: { send: vi.fn() } })), Menu: { setApplicationMenu: vi.fn() }, diff --git a/desktop/tests/session-meta-native-refusal.test.ts b/desktop/tests/session-meta-native-refusal.test.ts index a9d24b771..461b57dee 100644 --- a/desktop/tests/session-meta-native-refusal.test.ts +++ b/desktop/tests/session-meta-native-refusal.test.ts @@ -23,7 +23,7 @@ import os from 'node:os'; import path from 'node:path'; vi.mock('electron', () => ({ - app: { isPackaged: false, getPath: vi.fn(() => '/tmp'), getVersion: vi.fn(() => '0.0.0-test'), whenReady: vi.fn(() => new Promise(() => {})), on: vi.fn(), quit: vi.fn(), setAppUserModelId: vi.fn(), commandLine: { appendSwitch: vi.fn() }, getGPUInfo: vi.fn(() => new Promise(() => {})) }, + app: { isPackaged: false, isReady: vi.fn(() => false), getPath: vi.fn(() => '/tmp'), getVersion: vi.fn(() => '0.0.0-test'), whenReady: vi.fn(() => new Promise(() => {})), on: vi.fn(), quit: vi.fn(), setAppUserModelId: vi.fn(), commandLine: { appendSwitch: vi.fn() }, getGPUInfo: vi.fn(() => new Promise(() => {})) }, ipcMain: { handle: vi.fn(), on: vi.fn() }, BrowserWindow: vi.fn(() => ({ loadURL: vi.fn(), on: vi.fn(), webContents: { send: vi.fn() } })), Menu: { setApplicationMenu: vi.fn() },