Skip to content
Draft
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
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 43 additions & 11 deletions desktop/src/main/buddy-window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' });
Expand All @@ -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
Expand All @@ -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
Expand All @@ -571,15 +603,15 @@ 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).
// Recompute from scratch on visible: if the mascot lands on a bottom
// 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);
}
}

Expand All @@ -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();
}
Expand Down
57 changes: 57 additions & 0 deletions desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion desktop/tests/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
2 changes: 1 addition & 1 deletion desktop/tests/session-meta-native-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
Loading