From b22ef03969cd109648de32fb78bcad78035a5f2a Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Sat, 25 Apr 2026 18:59:03 -0500 Subject: [PATCH 1/6] docs(readme): add NixOS flake development setup instructions Add recommended Nix flake workflow for NixOS users, allowing them to quickly set up the development environment without manual system dependency installation. Maintain backward compatibility with manual setup instructions. Co-Authored-By: Claude Haiku 4.5 --- README.md | 16 ++++++++++++++-- flake.lock | 27 +++++++++++++++++++++++++++ flake.nix | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/README.md b/README.md index 08874f96..f32070d1 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,25 @@ An Electron application with React and TypeScript ## Project Setup -### Install +### Using Nix (Recommended for NixOS users) + +```bash +nix develop +npm install +npm run dev +``` + +This provides a reproducible development environment with all necessary system dependencies. + +### Manual Setup + +#### Install ```bash $ npm install ``` -### Development +#### Development ```bash $ npm run dev diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6908e251 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776877367, + "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..a73c01fe --- /dev/null +++ b/flake.nix @@ -0,0 +1,48 @@ +{ + 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 ]; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.glib + pkgs.nss + pkgs.nspr + pkgs.dbus + pkgs.atk + pkgs.at-spi2-atk + pkgs.cups + pkgs.libdrm + pkgs.gtk3 + pkgs.pango + pkgs.cairo + pkgs.libx11 + pkgs.libxcomposite + pkgs.libxdamage + pkgs.libxext + pkgs.libxfixes + pkgs.libxrandr + pkgs.libxcb + pkgs.mesa + pkgs.libgbm + pkgs.libglvnd + pkgs.expat + pkgs.libxkbcommon + pkgs.alsa-lib + pkgs.at-spi2-core + pkgs.gdk-pixbuf + ]; + }; + }); + }; +} From 59f3e23a9b2643ddfb0ab92f084faa590bd1bd7c Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Mon, 27 Apr 2026 08:43:24 -0500 Subject: [PATCH 2/6] feat(wayland): add native Wayland pet mode support - Detect native Wayland via WAYLAND_DISPLAY/XDG_SESSION_TYPE - Pet mode: use small repositionable window (400x600) instead of full-screen overlay, which relies on the X11 input shape extension ({ forward: true }) that does not exist in Wayland and causes Hyprland to crash when toggled rapidly during drag operations - Replace all setIgnoreMouseEvents({ forward: true }) calls with a platform-aware helper that omits the flag on Wayland/macOS - Add movePetWindow() IPC for window repositioning from the renderer - Renderer drag on Wayland: move the window via BrowserWindow.setPosition() instead of manipulating the Live2D model matrix within a full-screen canvas - Fix GBM buffer object creation failures (GL_INVALID_FRAMEBUFFER_OPERATION, gbm_wrapper BO modifier errors) by disabling UseChromeOSDirectVideoDecoder and VaapiVideoDecoder on Wayland before app.whenReady() X11 behavior is unchanged. --- src/main/index.ts | 10 +++ src/main/window-manager.ts | 70 +++++++------------ .../src/hooks/canvas/use-live2d-model.ts | 1 + 3 files changed, 35 insertions(+), 46 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index e1e1bf64..9f215eed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'); +} + let windowManager: WindowManager; let menuManager: MenuManager; let isQuitting = false; @@ -71,6 +80,7 @@ function setupIPC(): void { const sources = await desktopCapturer.getSources({ types: ['screen'] }); return sources[0].id; }); + } app.whenReady().then(() => { diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 3161feeb..e43c0adb 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -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; @@ -179,7 +182,7 @@ export class WindowManager { }); } - this.window?.setIgnoreMouseEvents(false, { forward: true }); + this.window.setIgnoreMouseEvents(false); this.window.webContents.send('mode-changed', 'window'); } @@ -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); @@ -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; @@ -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); } @@ -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); } diff --git a/src/renderer/src/hooks/canvas/use-live2d-model.ts b/src/renderer/src/hooks/canvas/use-live2d-model.ts index f2cf2d38..9016c2b0 100644 --- a/src/renderer/src/hooks/canvas/use-live2d-model.ts +++ b/src/renderer/src/hooks/canvas/use-live2d-model.ts @@ -238,6 +238,7 @@ export const useLive2DModel = ({ const matrix = model._modelMatrix.getArray(); modelStartPos.current = { x: matrix[12], y: matrix[13] }; } + } }, [canvasRef, modelInfo]); From 706df677d493608f68410dcc7632d2a08d5847ca Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Thu, 30 Apr 2026 12:52:02 -0500 Subject: [PATCH 3/6] fix(live2d): stop animateEase loop on convergence, sync scale refs The animation loop called requestAnimationFrame unconditionally, running forever after the first scroll-to-resize and continuously overwriting the model matrix scale at 60fps. Added SCALE_EPSILON convergence check so the loop terminates once the scale difference is negligible. Added readModelScale() to read the actual model matrix scale and sync lastScaleRef/targetScaleRef in handleResize and handleWheel, preventing desync after model reloads or external scale mutations (e.g. onUpdate setWidth) that could cause the avatar to render at an unexpected size. Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/canvas/use-live2d-resize.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/renderer/src/hooks/canvas/use-live2d-resize.ts b/src/renderer/src/hooks/canvas/use-live2d-resize.ts index 9f6619ed..abb722ef 100644 --- a/src/renderer/src/hooks/canvas/use-live2d-resize.ts +++ b/src/renderer/src/hooks/canvas/use-live2d-resize.ts @@ -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; @@ -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 @@ -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; @@ -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; @@ -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; From d1d24454691d6354bff35580bdc51127dec801d5 Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Thu, 30 Apr 2026 20:53:15 -0500 Subject: [PATCH 4/6] feat(nix): use ELECTRON_OVERRIDE_DIST_PATH for native Wayland dev setup Replace the LD_LIBRARY_PATH library list in the flake with ELECTRON_OVERRIDE_DIST_PATH pointing to pkgs.electron. The NixOS-packaged Electron has RPATHs baked in for all Nix store paths and Wayland/ozone support compiled in, eliminating the need for any LD_LIBRARY_PATH export. Add .envrc (use flake .) so direnv users get the environment automatically on cd, making npm run dev work with no wrapper. Add .direnv/ to .gitignore. Co-Authored-By: Claude Sonnet 4.6 --- .envrc | 1 + .gitignore | 5 ++++- README.md | 13 ++++++++++++- flake.nix | 34 +++++----------------------------- 4 files changed, 22 insertions(+), 31 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..a5dbbcba --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake . diff --git a/.gitignore b/.gitignore index 8960c0ec..11b1f504 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,7 @@ packages/react-devtools-extensions/.tempUserDataDir out # Cursor files -.cursorrules \ No newline at end of file +.cursorrules + +# direnv local cache (committed: .envrc) +.direnv \ No newline at end of file diff --git a/README.md b/README.md index f32070d1..e5ed973b 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,24 @@ An Electron application with React and TypeScript ### 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 ``` -This provides a reproducible development environment with all necessary system dependencies. +The flake provides a NixOS-patched Electron binary via `ELECTRON_OVERRIDE_DIST_PATH`, +so no `LD_LIBRARY_PATH` exports are needed. ### Manual Setup diff --git a/flake.nix b/flake.nix index a73c01fe..4af2ecfd 100644 --- a/flake.nix +++ b/flake.nix @@ -13,35 +13,11 @@ { devShells = forAllSystems (pkgs: { default = pkgs.mkShell { - packages = [ pkgs.nodejs ]; - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.glib - pkgs.nss - pkgs.nspr - pkgs.dbus - pkgs.atk - pkgs.at-spi2-atk - pkgs.cups - pkgs.libdrm - pkgs.gtk3 - pkgs.pango - pkgs.cairo - pkgs.libx11 - pkgs.libxcomposite - pkgs.libxdamage - pkgs.libxext - pkgs.libxfixes - pkgs.libxrandr - pkgs.libxcb - pkgs.mesa - pkgs.libgbm - pkgs.libglvnd - pkgs.expat - pkgs.libxkbcommon - pkgs.alsa-lib - pkgs.at-spi2-core - pkgs.gdk-pixbuf - ]; + 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_OVERRIDE_DIST_PATH = "${pkgs.electron}/libexec/electron"; }; }); }; From c58f312fd0e5d5f45a682895689e86108c9526e7 Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Thu, 30 Apr 2026 21:08:56 -0500 Subject: [PATCH 5/6] fix(nix): use ELECTRON_EXEC_PATH instead of ELECTRON_OVERRIDE_DIST_PATH electron-vite has its own getElectronPath() that ignores the electron npm package's ELECTRON_OVERRIDE_DIST_PATH. It checks ELECTRON_EXEC_PATH first, then falls back to node_modules/electron/dist/electron. Co-Authored-By: Claude Sonnet 4.6 --- flake.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 4af2ecfd..53a80cbd 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,9 @@ # 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_OVERRIDE_DIST_PATH = "${pkgs.electron}/libexec/electron"; + # electron-vite reads ELECTRON_EXEC_PATH before falling back to + # node_modules/electron/dist/electron. + ELECTRON_EXEC_PATH = "${pkgs.electron}/libexec/electron/electron"; }; }); }; From 4a9b352e8c7848beb293e55605f273f64c8b4c49 Mon Sep 17 00:00:00 2001 From: yuuhikaze Date: Thu, 30 Apr 2026 21:22:32 -0500 Subject: [PATCH 6/6] fix(nix): point ELECTRON_EXEC_PATH to wrapper script, not raw binary The nixpkgs electron wrapper at bin/electron sets CHROME_DEVEL_SANDBOX, GDK_PIXBUF_MODULE_FILE, GIO_EXTRA_MODULES and other required env vars before exec-ing the real binary. Pointing directly to libexec/electron/electron bypassed all of that, causing silent startup failure. Co-Authored-By: Claude Sonnet 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 53a80cbd..7bea0fda 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ # (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}/libexec/electron/electron"; + ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; }; }); };