Skip to content
Merged
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
45 changes: 39 additions & 6 deletions electron/src/lib/glass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}
Expand All @@ -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 } : {});
Comment on lines +85 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Queue tint updates before native glass handle exists

setGlassTint returns immediately when storedHandle is still null, which drops early tint IPC events instead of replaying them later. On startup, useSpaceTheme can emit glass:set-tint-color before createWindow’s did-finish-load callback runs applyGlass, so the initial space tint is lost and the window stays untinted until another space/theme change triggers a new tint update. Caching the latest tint and applying it once applyGlass sets the handle would avoid this startup race.

Useful? React with 👍 / 👎.

}
30 changes: 27 additions & 3 deletions electron/src/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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}`);
}
});

}
}

Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions electron/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,29 @@ 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);
Comment thread
OpenSource03 marked this conversation as resolved.
} else {
ipcRenderer.send("glass:set-theme", "dark");
}
} catch (e) {
console.error("[preload] early setup failed:", e);
}

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) =>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
OpenSource03 marked this conversation as resolved.
"electron-updater": "^6.8.3",
"konva": "^10.2.0",
"mermaid": "^11.13.0",
Expand Down
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

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

22 changes: 22 additions & 0 deletions src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──

Expand Down Expand Up @@ -380,6 +395,13 @@ Link: ${issue.url}`;
style={glassOverlayStyle}
/>
)}
{/* Unfocused veil — subtle dim/brighten on macOS liquid glass when window loses focus */}
{isNativeGlass && (
<div
className={`pointer-events-none fixed inset-0 z-0 transition-opacity duration-300 ${windowFocused ? "opacity-0" : "opacity-100"}`}
style={{ background: isLightGlass ? "rgba(255,255,255,0.38)" : "rgba(0,0,0,0.34)" }}
/>
)}
<SpaceCreator
open={spaceCreatorOpen}
onOpenChange={setSpaceCreatorOpen}
Expand Down
22 changes: 21 additions & 1 deletion src/hooks/useSpaceTheme.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import type { Space } from "@/types";
import { computeGlassTintColor } from "@/lib/color-utils";
import { isMac } from "@/lib/utils";

const TINT_VARS = [
"--space-hue", "--space-chroma",
Expand All @@ -22,6 +24,10 @@ function getTintStrength(chroma: number): number {
* Applies the active space's color tint to CSS custom properties on the document root.
* Handles dark/light mode branching and glass/non-glass transparency.
*
* On macOS with native glass, sends tintColor to the main process via IPC so
* the glass material is tinted natively (higher quality than CSS overlay).
* Falls back to CSS overlay on non-macOS platforms (Windows Mica, etc.).
*
* Returns the glass overlay style object (or null) for the tint overlay div.
*/
export function useSpaceTheme(
Expand All @@ -36,11 +42,15 @@ export function useSpaceTheme(
const root = document.documentElement;
const isGlass = isGlassActive;
const isDark = resolvedTheme === "dark";
// Native macOS glass supports tintColor via addView()
const isNativeGlass = isGlass && isMac;

if (!space || space.color.chroma === 0) {
// Clear all tinted vars so the CSS base values take over
for (const v of TINT_VARS) root.style.removeProperty(v);
setGlassOverlayStyle(null);
// Clear native glass tint when space has no color
if (isNativeGlass) window.claude.glass?.setTintColor(null);

// Still apply opacity even for colorless (default) space
const opacity = space?.color.opacity;
Expand Down Expand Up @@ -116,7 +126,16 @@ export function useSpaceTheme(
const gradientHue = space.color.gradientHue;
const overlayChroma = Math.min(0.18, 0.04 + 0.12 * tintStrength);

if (isGlass) {
// ── Glass tinting ──
if (isNativeGlass) {
// Native macOS glass tinting via addView({ tintColor }).
// The main process also re-applies tint on window focus (macOS drops it when inactive).
const hexTint = computeGlassTintColor(space.color);
window.claude.glass?.setTintColor(hexTint);
// Native tint handles the glass material — no CSS overlay needed
setGlassOverlayStyle(null);
} else if (isGlass) {
// Non-macOS glass (Windows Mica, etc.) — CSS overlay for tinting
const a = 0.04 + 0.12 * tintStrength;
const bg = gradientHue !== undefined
? `linear-gradient(135deg, oklch(0.5 ${overlayChroma} ${hue} / ${a}), oklch(0.5 ${overlayChroma} ${gradientHue} / ${a}))`
Expand All @@ -140,6 +159,7 @@ export function useSpaceTheme(
return () => {
for (const v of TINT_VARS) root.style.removeProperty(v);
setGlassOverlayStyle(null);
if (isNativeGlass) window.claude.glass?.setTintColor(null);
};
}, [activeSpace, resolvedTheme, isGlassActive]);

Expand Down
7 changes: 7 additions & 0 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,12 @@ export function useTheme(theme: ThemeOption): ResolvedTheme {
}
}, [resolved]);

// Sync theme to main process so native glass appearance matches the app theme.
// Send the raw option ("light"/"dark"/"system"), not the resolved value,
// so nativeTheme.themeSource = "system" lets macOS drive glass appearance natively.
useEffect(() => {
window.claude.glass?.setTheme(theme);
}, [theme]);

return resolved;
}
32 changes: 2 additions & 30 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -157,36 +157,8 @@ html.glass-enabled #root {
--sidebar: oklch(1 0 0 / 0.29);
--sidebar-border: oklch(0 0 0 / 0.08);
--sidebar-accent: oklch(0.965 0.001 286.029 / 0.3);
/* White text on glass sidebar — native glass shows through dark, so sidebar
text should be light (like dark mode) regardless of the app theme.
Exception: search input keeps dark text (see below). */
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.64 0 0);
/* Keep --sidebar-accent-foreground at light-mode default for other accent
surfaces; selected chats are overridden below. */
}

/* Elements on light backgrounds (search box) — reset to dark text/icons */
.glass-enabled:not(.dark) .glass-outline,
.glass-enabled:not(.dark) .bg-sidebar-accent {
--sidebar-foreground: oklch(0.145 0 0);
}

/* Sidebar search stays on the white-text treatment in light glass mode. */
.glass-enabled:not(.dark) .sidebar-search-glass {
--sidebar-foreground: oklch(0.985 0 0);
}

/* In light mode with glass enabled, selected chats should stay white. */
.glass-enabled:not(.dark) .session-item-active {
color: oklch(0.985 0 0);
}

/* In light glass mode, sidebar engine icons match white text via inversion */
.glass-enabled:not(.dark) .session-item-button img {
filter: invert(1) brightness(2);
/* Light mode glass: keep dark text (same as Windows) — the always-key-appearance
fix keeps the glass bright enough for standard dark-on-light text. */
}

/* In glass mode, only the outermost bg-sidebar (AppLayout root) provides
Expand Down
Loading