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
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use flake .
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,7 @@ packages/react-devtools-extensions/.tempUserDataDir
out

# Cursor files
.cursorrules
.cursorrules

# direnv local cache (committed: .envrc)
.direnv
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,36 @@ An Electron application with React and TypeScript

## Project Setup

### Install
### Using Nix (Recommended for NixOS users)

With [direnv](https://direnv.net/) and [nix-direnv](https://github.com/nix-community/nix-direnv):

```bash
direnv allow # one-time, auto-loads the flake on every cd
npm install
npm run dev
```

Without direnv:

```bash
nix develop
npm install
npm run dev
```

The flake provides a NixOS-patched Electron binary via `ELECTRON_OVERRIDE_DIST_PATH`,
so no `LD_LIBRARY_PATH` exports are needed.
Comment on lines +29 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix env var name mismatch in docs (ELECTRON_OVERRIDE_DIST_PATH vs ELECTRON_EXEC_PATH).

Line 29 currently documents ELECTRON_OVERRIDE_DIST_PATH, but flake.nix exports ELECTRON_EXEC_PATH. This will send users to the wrong knob.

Suggested doc fix
-The flake provides a NixOS-patched Electron binary via `ELECTRON_OVERRIDE_DIST_PATH`,
+The flake provides a NixOS-patched Electron binary via `ELECTRON_EXEC_PATH`,
 so no `LD_LIBRARY_PATH` exports are needed.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
The flake provides a NixOS-patched Electron binary via `ELECTRON_OVERRIDE_DIST_PATH`,
so no `LD_LIBRARY_PATH` exports are needed.
The flake provides a NixOS-patched Electron binary via `ELECTRON_EXEC_PATH`,
so no `LD_LIBRARY_PATH` exports are needed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 29 - 30, Update the README to reference the actual
environment variable exported by the flake: replace mentions of
ELECTRON_OVERRIDE_DIST_PATH with ELECTRON_EXEC_PATH so docs match flake.nix;
search for the README text that currently says "ELECTRON_OVERRIDE_DIST_PATH" and
change it to "ELECTRON_EXEC_PATH" (and optionally add a brief parenthetical if
needed to clarify it is provided by the flake) to ensure users are directed to
the correct knob.


### Manual Setup

#### Install

```bash
$ npm install
```

### Development
#### Development

```bash
$ npm run dev
Expand Down
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

outputs =
{ nixpkgs, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.nodejs pkgs.electron ];
# Use the NixOS-patched Electron instead of the pre-built npm binary.
# pkgs.electron has correct RPATHs baked in, supports native Wayland
# (ozone/Chromium), and needs no LD_LIBRARY_PATH workarounds.
# electron-vite reads ELECTRON_EXEC_PATH before falling back to
# node_modules/electron/dist/electron.
ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron";
};
});
};
}
10 changes: 10 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { electronApp, optimizer } from "@electron-toolkit/utils";
import { WindowManager } from "./window-manager";
import { MenuManager } from "./menu-manager";

// Fix GBM buffer object creation failures with DRM format modifiers on Wayland.
// These switches must be set before app.whenReady().
if (process.platform === 'linux' && (process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland')) {
app.commandLine.appendSwitch('disable-features', 'UseChromeOSDirectVideoDecoder,VaapiVideoDecoder');
// Disable DMA-buf zero-copy scanout path that triggers GBM BO modifier allocation failures
// on some GPU/driver combinations when Electron resizes GPU-backed surfaces on Wayland.
app.commandLine.appendSwitch('disable-zero-copy');
}
Comment on lines +7 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Electron on Linux, do WAYLAND_DISPLAYorXDG_SESSION_TYPE=wayland guarantee that the app is using the native Wayland backend, or can Electron still run under X11/XWayland unless Ozone platform flags or environment variables are set?

💡 Result:

They do not guarantee it. On Linux, Electron’s choice between native Wayland and X11/XWayland depends on Ozone platform selection (historically via flags and/or environment variables), and environment variables like WAYLAND_DISPLAY and XDG_SESSION_TYPE=wayland can be insufficient by themselves. 1) What the env vars do (and don’t) do - WAYLAND_DISPLAY and/or XDG_SESSION_TYPE=wayland indicate you are in a Wayland session, but they are not, on their own, a contractual “Electron must use the native Wayland Ozone backend” guarantee. - Electron’s backend selection is controlled by Ozone platform settings (e.g. --ozone-platform=wayland or --ozone-platform=x11), and/or Ozone platform “hint” logic that inspects XDG_SESSION_TYPE (or had similar behavior historically). 2) Native Wayland in Electron depends on Ozone/flags (and can still fall back to XWayland) - Electron documents that running on Wayland sessions historically did not automatically mean native Wayland; instead apps could use X11 via Xwayland unless the Ozone/Wayland platform path is selected. It explicitly contrasts “native Wayland” vs “running inside an invisible X server called Xwayland.” - Electron 38.0.0+ introduced native Wayland by default in Wayland sessions, but you can still force X11 compatibility mode with --ozone-platform=x11. 3) Evidence that “setting WAYLAND/XDG vars” alone can still result in XWayland - Real-world reports show Electron apps can choose XWayland even when started in a Wayland session, due to launchers/packaging setting Ozone hints to auto (or using other launcher logic), or due to Electron/version-specific Wayland detection issues. Practical conclusion - If you need to ensure native Wayland (no XWayland), set Electron explicitly to Wayland using Ozone flags, e.g. --enable-features=UseOzonePlatform --ozone-platform=wayland (or set the relevant Ozone hint/auto logic if your Electron version supports it as the default). - Do not assume that merely having WAYLAND_DISPLAY set or exporting XDG_SESSION_TYPE=wayland will always force native Wayland in Electron; Electron can still run via X11/XWayland unless its Ozone platform selection is aligned with Wayland. How to verify at runtime - Check whether the window is registered as an X11/XWayland surface (e.g., using tools like xwininfo/xprop/xlsclients) rather than relying solely on session env vars. - If you see X11/XWayland indicators, you’ll need to adjust Electron launch options (Ozone flags/hints) for your specific app/version.

Citations:


🏁 Script executed:

rg -n "WAYLAND_DISPLAY|XDG_SESSION_TYPE" src/main/window-manager.ts -A 3 -B 3

Repository: Open-LLM-VTuber/Open-LLM-VTuber-Web

Length of output: 352


🏁 Script executed:

rg -n "isWayland" src/main/window-manager.ts -A 2 -B 2

Repository: Open-LLM-VTuber/Open-LLM-VTuber-Web

Length of output: 471


Gate these switches on Electron's actual backend, not just the desktop session.

WAYLAND_DISPLAY / XDG_SESSION_TYPE=wayland do not guarantee that Electron is using the native Wayland backend—Electron can still run under X11/XWayland. This predicate disables decoders and zero-copy on the wrong backend. The same unreliable check is duplicated in src/main/window-manager.ts (lines 8–10), where it controls setIgnoreMouseEvents() behavior (line 246), so a false positive here becomes a cross-file regression affecting both GPU decoding and mouse-event forwarding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/index.ts` around lines 7 - 14, The current env-based predicate
falsely assumes Wayland is in use; replace it with a backend-aware check before
app.whenReady() by creating a shared helper (e.g., isWaylandBackend()) and use
it in both src/main/index.ts (the app.commandLine.appendSwitch block) and
src/main/window-manager.ts (the setIgnoreMouseEvents logic). Implement
isWaylandBackend() to detect Electron's actual backend by checking explicit
backend signals available before ready—first look for explicit ozone flags
(process.env.OZONE_PLATFORM === 'wayland' or process.argv includes
'--ozone-platform=wayland'), then fall back to the session env vars only as a
last resort; call that helper to gate the appendSwitch calls and the
setIgnoreMouseEvents behavior so the switches are only disabled when the
Wayland/Ozone backend is actually in use.


let windowManager: WindowManager;
let menuManager: MenuManager;
let isQuitting = false;
Expand Down Expand Up @@ -71,6 +80,7 @@ function setupIPC(): void {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
return sources[0].id;
});

}

app.whenReady().then(() => {
Expand Down
70 changes: 24 additions & 46 deletions src/main/window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { join } from 'path';
import { is } from '@electron-toolkit/utils';

const isMac = process.platform === 'darwin';
const isWayland =
process.platform === 'linux' &&
(!!process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland');

export class WindowManager {
private window: BrowserWindow | null = null;
Expand Down Expand Up @@ -179,7 +182,7 @@ export class WindowManager {
});
}

this.window?.setIgnoreMouseEvents(false, { forward: true });
this.window.setIgnoreMouseEvents(false);

this.window.webContents.send('mode-changed', 'window');
}
Expand All @@ -203,24 +206,20 @@ export class WindowManager {

private continueSetWindowModePet(): void {
if (!this.window) return;
// Calculate the bounding rectangle that covers all connected displays.
// This allows the transparent pet-mode window to span across monitors,
// so the avatar can be dragged freely between them.

// Span all connected displays so the avatar can be dragged freely between monitors.
// On Wayland the overlay works identically — applyIgnoreMouseEvents strips the
// X11-only { forward: true } flag which was the original crash trigger.
const displays = screen.getAllDisplays();
const minX = Math.min(...displays.map((d) => d.bounds.x));
const minY = Math.min(...displays.map((d) => d.bounds.y));
const maxX = Math.max(...displays.map((d) => d.bounds.x + d.bounds.width));
const maxY = Math.max(...displays.map((d) => d.bounds.y + d.bounds.height));
const combinedWidth = maxX - minX;
const combinedHeight = maxY - minY;

// Resize and position the window to cover the entire virtual screen
// so the avatar is not clipped when dragged to a second monitor.
this.window.setBounds({
x: minX,
y: minY,
width: combinedWidth,
height: combinedHeight,
width: maxX - minX,
height: maxY - minY,
});

if (isMac) this.window.setWindowButtonVisibility(false);
Expand All @@ -229,32 +228,32 @@ export class WindowManager {
this.window.setFocusable(false);

if (isMac) {
this.window.setIgnoreMouseEvents(true);
this.window.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
} else {
this.window.setIgnoreMouseEvents(true, { forward: true });
this.window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}

this.applyIgnoreMouseEvents(true);
this.window.webContents.send('mode-changed', 'pet');
}

getWindow(): BrowserWindow | null {
return this.window;
}

setIgnoreMouseEvents(ignore: boolean): void {
// Applies setIgnoreMouseEvents with platform-correct options.
// { forward: true } is an X11 input-shape feature — on Wayland it causes compositor crashes.
private applyIgnoreMouseEvents(ignore: boolean): void {
if (!this.window) return;

if (isMac) {
if (isMac || isWayland) {
this.window.setIgnoreMouseEvents(ignore);
// this.window.setIgnoreMouseEvents(ignore, { forward: true });
} else {
this.window.setIgnoreMouseEvents(ignore, { forward: true });
}
}

setIgnoreMouseEvents(ignore: boolean): void {
this.applyIgnoreMouseEvents(ignore);
}

maximizeWindow(): void {
if (!this.window) return;

Expand Down Expand Up @@ -295,11 +294,7 @@ export class WindowManager {

if (this.window) {
const shouldIgnore = this.hoveringComponents.size === 0;
if (isMac) {
this.window.setIgnoreMouseEvents(shouldIgnore);
} else {
this.window.setIgnoreMouseEvents(shouldIgnore, { forward: true });
}
this.applyIgnoreMouseEvents(shouldIgnore);
if (!shouldIgnore) {
this.window.setFocusable(true);
}
Expand All @@ -309,25 +304,8 @@ export class WindowManager {
// Toggle force ignore mouse events
toggleForceIgnoreMouse(): void {
this.forceIgnoreMouse = !this.forceIgnoreMouse;

// Apply the new setting immediately
if (this.forceIgnoreMouse) {
if (isMac) {
this.window?.setIgnoreMouseEvents(true);
} else {
this.window?.setIgnoreMouseEvents(true, { forward: true });
}
} else {
// Reapply normal behavior based on hovering components
const shouldIgnore = this.hoveringComponents.size === 0;
if (isMac) {
this.window?.setIgnoreMouseEvents(shouldIgnore);
} else {
this.window?.setIgnoreMouseEvents(shouldIgnore, { forward: true });
}
}

// Notify renderer about the change
const shouldIgnore = this.forceIgnoreMouse || this.hoveringComponents.size === 0;
this.applyIgnoreMouseEvents(shouldIgnore);
this.window?.webContents.send('force-ignore-mouse-changed', this.forceIgnoreMouse);
}

Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/hooks/canvas/use-live2d-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export const useLive2DModel = ({
const matrix = model._modelMatrix.getArray();
modelStartPos.current = { x: matrix[12], y: matrix[13] };
}

}
}, [canvasRef, modelInfo]);

Expand Down
44 changes: 44 additions & 0 deletions src/renderer/src/hooks/canvas/use-live2d-resize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const MAX_SCALE = 5.0;
const EASING_FACTOR = 0.3; // Controls animation smoothness
const WHEEL_SCALE_STEP = 0.03; // Scale change per wheel tick
const DEFAULT_SCALE = 1.0; // Default scale if not specified
const SCALE_EPSILON = 0.0005; // Convergence threshold for animation loop

interface UseLive2DResizeProps {
containerRef: RefObject<HTMLDivElement>;
Expand All @@ -39,6 +40,23 @@ export const applyScale = (scale: number) => {
}
};

/**
* Reads the current model matrix scale (X axis).
* Returns undefined if the model isn't ready.
*/
const readModelScale = (): number | undefined => {
try {
const manager = LAppLive2DManager.getInstance();
if (!manager) return undefined;
const model = manager.getModel(0);
if (!model) return undefined;
// @ts-ignore
return model._modelMatrix.getScaleX();
} catch {
return undefined;
}
};

/**
* Hook to handle Live2D model resizing and scaling
* Provides smooth scaling animation and window resize handling
Expand Down Expand Up @@ -102,6 +120,13 @@ export const useLive2DResize = ({
const currentScale = lastScaleRef.current;
const diff = clampedTargetScale - currentScale;

if (Math.abs(diff) < SCALE_EPSILON) {
applyScale(clampedTargetScale);
lastScaleRef.current = clampedTargetScale;
isAnimatingRef.current = false;
return;
}

const newScale = currentScale + diff * EASING_FACTOR;
applyScale(newScale);
lastScaleRef.current = newScale;
Expand All @@ -117,6 +142,15 @@ export const useLive2DResize = ({
e.preventDefault();
if (!modelInfo?.scrollToResize) return;

// Sync from actual model scale before computing new target to prevent
// jumps when refs drifted (e.g. after model reload while animation was stopped).
if (!isAnimatingRef.current) {
const actual = readModelScale();
if (actual !== undefined && actual > 0) {
lastScaleRef.current = actual;
}
}

const direction = e.deltaY > 0 ? -1 : 1;
const increment = WHEEL_SCALE_STEP * direction;

Expand Down Expand Up @@ -205,6 +239,16 @@ export const useLive2DResize = ({
console.warn('[Resize] LAppDelegate instance not found.');
}

// Sync scale refs with the actual model matrix to prevent desync
// after model reloads or external scale changes (e.g. onUpdate setWidth).
if (!isAnimatingRef.current) {
const actualScale = readModelScale();
if (actualScale !== undefined && actualScale > 0) {
lastScaleRef.current = actualScale;
targetScaleRef.current = actualScale;
}
}

isResizingRef.current = false;
} catch (error) {
isResizingRef.current = false;
Expand Down