diff --git a/electron/src/lib/glass.ts b/electron/src/lib/glass.ts index c64efd0..544cb7b 100644 --- a/electron/src/lib/glass.ts +++ b/electron/src/lib/glass.ts @@ -3,8 +3,14 @@ import os from "os"; import { log } from "./logger"; import { reportError } from "./error-utils"; +interface GlassOptions { + tintColor?: string; + cornerRadius?: number; + opaque?: boolean; +} + interface LiquidGlass { - addView: (handle: Buffer, opts?: object) => number; + addView: (handle: Buffer, opts?: GlassOptions) => number; } let liquidGlass: LiquidGlass | null = null; @@ -18,22 +24,32 @@ if (process.platform === "darwin") { // mainEntry = .../electron-liquid-glass/dist/index.cjs → go up past "dist/" const pkgDir = path.dirname(path.dirname(mainEntry)); - // Load the .node prebuild directly, bypassing node-gyp-build which - // isn't available in ASAR builds (pnpm doesn't hoist transitive deps) + // Load the .node addon directly — try prebuild first, fall back to + // electron-rebuild output (build/Release/). + // Prebuilds aren't available for GitHub forks that lack CI-built binaries. const prebuildFile = process.arch === "arm64" ? "node.napi.armv8.node" : "node.napi.node"; const prebuildPath = path.join( pkgDir, "prebuilds", `darwin-${process.arch}`, prebuildFile ); + const buildReleasePath = path.join(pkgDir, "build", "Release", "liquidglass.node"); + + let addonPath: string; + try { + require("fs").accessSync(prebuildPath); + addonPath = prebuildPath; + } catch { + addonPath = buildReleasePath; + } // eslint-disable-next-line @typescript-eslint/no-require-imports - const native = require(prebuildPath); + const native = require(addonPath); // Instantiate the native class directly (same as the library's JS wrapper does internally) const addon = new native.LiquidGlassNative(); if (addon && typeof addon.addView === "function") { liquidGlass = addon; - log("GLASS", `Native addon loaded from ${prebuildPath}`); + log("GLASS", `Native addon loaded from ${addonPath}`); } else { log("GLASS", "Native addon loaded but addView not found"); } @@ -51,4 +67,21 @@ function isMacOSSequoiaOrLater(): boolean { } export const glassEnabled = !!(liquidGlass && isMacOSSequoiaOrLater()); -export { liquidGlass }; + +// ── Dynamic tint support ── +// Store the window handle so we can re-call addView() with updated tintColor. +// The C++ AddGlassEffectView auto-removes previous glass views before creating +// new ones in a single dispatch_sync block, so there is no visual gap. + +let storedHandle: Buffer | null = null; + +export function applyGlass(handle: Buffer, opts?: GlassOptions): number { + if (!liquidGlass) return -1; + storedHandle = handle; + return liquidGlass.addView(handle, opts ?? {}); +} + +export function setGlassTint(tintColor: string | null): number { + if (!liquidGlass || !storedHandle) return -1; + return liquidGlass.addView(storedHandle, tintColor ? { tintColor } : {}); +} diff --git a/electron/src/main.ts b/electron/src/main.ts index afaf703..4df309b 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, session, shell, systemPreferences } from "electron"; +import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, systemPreferences } from "electron"; import path from "path"; import http from "http"; import contextMenu from "electron-context-menu"; @@ -22,7 +22,7 @@ if (process.platform !== "win32") { import { log } from "./lib/logger"; import { reportError } from "./lib/error-utils"; import { migrateFromOpenAcpUi } from "./lib/migration"; -import { glassEnabled, liquidGlass } from "./lib/glass"; +import { glassEnabled, applyGlass, setGlassTint } from "./lib/glass"; import { initAutoUpdater, getIsInstallingUpdate } from "./lib/updater"; import { initPostHog, shutdownPostHog, reinitPostHog, captureEvent } from "./lib/posthog"; import { sessions } from "./ipc/claude-sessions"; @@ -141,13 +141,14 @@ function createWindow(): void { if (glassEnabled) { // macOS: apply liquid glass after content loads mainWindow.webContents.once("did-finish-load", () => { - const glassId = liquidGlass!.addView(mainWindow!.getNativeWindowHandle(), {}); + const glassId = applyGlass(mainWindow!.getNativeWindowHandle()); if (glassId === -1) { log("GLASS", "addView returned -1 — native addon failed, glass will not be visible"); } else { log("GLASS", `Liquid glass applied, viewId=${glassId}`); } }); + } } @@ -181,6 +182,29 @@ ipcMain.on("app:set-min-width", (_event, minWidth: number) => { } }); +// Native glass tint — re-creates glass view with updated tintColor. +// The C++ addon auto-cleans previous views in a single dispatch_sync block. +const GLASS_TINT_RE = /^#[0-9a-fA-F]{8}$/; +ipcMain.on("glass:set-tint-color", (_event, tintColor: string | null) => { + if (!glassEnabled) return; + if (tintColor !== null && (typeof tintColor !== "string" || !GLASS_TINT_RE.test(tintColor))) { + log("GLASS", `Ignoring invalid tintColor: ${String(tintColor)}`); + return; + } + const viewId = setGlassTint(tintColor); + if (viewId >= 0) { + log("GLASS", `setTintColor=${tintColor}, viewId=${viewId}`); + } +}); + +// Glass appearance — force light/dark/system on the native layer so the +// glass effect follows the app's theme setting, not just the OS preference. +ipcMain.on("glass:set-theme", (_event, theme: string) => { + if (theme === "light" || theme === "dark" || theme === "system") { + nativeTheme.themeSource = theme; + } +}); + // --- Register all IPC modules --- spacesIpc.register(); projectsIpc.register(getMainWindow); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 09a50dd..9c12ae0 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -36,6 +36,16 @@ try { root.classList.add("glass-enabled"); } }); + + // Push stored theme to main process early so glass appearance is correct + // before React mounts. Default to "dark" to match useSettings, which falls + // back to "dark" when harnss-theme is unset — avoids a system→dark flash. + const storedTheme = globals.localStorage?.getItem("harnss-theme"); + if (storedTheme === "light" || storedTheme === "dark" || storedTheme === "system") { + ipcRenderer.send("glass:set-theme", storedTheme); + } else { + ipcRenderer.send("glass:set-theme", "dark"); + } } catch (e) { console.error("[preload] early setup failed:", e); } @@ -43,6 +53,12 @@ try { contextBridge.exposeInMainWorld("claude", { getGlassSupported: () => ipcRenderer.invoke("app:getGlassSupported"), setMinWidth: (width: number) => ipcRenderer.send("app:set-min-width", width), + glass: { + setTintColor: (tintColor: string | null) => + ipcRenderer.send("glass:set-tint-color", tintColor), + setTheme: (theme: string) => + ipcRenderer.send("glass:set-theme", theme), + }, start: (options: unknown) => ipcRenderer.invoke("claude:start", options), send: (sessionId: string, message: unknown) => ipcRenderer.invoke("claude:send", { sessionId, message }), stop: (sessionId: string, reason?: string) => diff --git a/package.json b/package.json index e1f39ee..745dd69 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@posthog/react": "^1.8.2", "@tanstack/react-virtual": "^3.13.22", "electron-context-menu": "^4.1.1", - "electron-liquid-glass": "^1.1.1", + "electron-liquid-glass": "github:darkkatarsis/electron-liquid-glass", "electron-updater": "^6.8.3", "konva": "^10.2.0", "mermaid": "^11.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80e1106..d55e3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^4.1.1 version: 4.1.1 electron-liquid-glass: - specifier: ^1.1.1 - version: 1.1.1 + specifier: github:darkkatarsis/electron-liquid-glass + version: https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc electron-updater: specifier: ^6.8.3 version: 6.8.3 @@ -3079,8 +3079,9 @@ packages: resolution: {integrity: sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q==} engines: {node: '>=18'} - electron-liquid-glass@1.1.1: - resolution: {integrity: sha512-AfPxcu6RJYxlsW2sjgjDEON0rVOjhh5QYM6ur5hsfILQmfY5ewHCWJZJZoDsQxhjeW1DAhbRbvB79IvgW4ansA==} + electron-liquid-glass@https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc: + resolution: {tarball: https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc} + version: 1.1.1 engines: {node: '>=18'} os: [darwin] @@ -8703,7 +8704,7 @@ snapshots: electron-is-dev@3.0.1: {} - electron-liquid-glass@1.1.1: + electron-liquid-glass@https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc: dependencies: bindings: 1.5.0 node-addon-api: 8.5.0 diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index df72cab..a615035 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -85,6 +85,21 @@ export function AppLayout() { ); const isGlassActive = glassSupported && settings.transparency; const isLightGlass = isGlassActive && resolvedTheme !== "dark"; + const isNativeGlass = isGlassActive && isMac; + + // ── Window focus tracking (subtle veil on macOS liquid glass when unfocused) ── + const [windowFocused, setWindowFocused] = useState(true); + useEffect(() => { + if (!isNativeGlass) return; + const onFocus = () => setWindowFocused(true); + const onBlur = () => setWindowFocused(false); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + }; + }, [isNativeGlass]); // ── Welcome wizard (first-run onboarding) ── @@ -380,6 +395,13 @@ Link: ${issue.url}`; style={glassOverlayStyle} /> )} + {/* Unfocused veil — subtle dim/brighten on macOS liquid glass when window loses focus */} + {isNativeGlass && ( +
+ )}