feat(desktop): integrate electron-liquid-glass for macOS Tahoe (#12277)

*  feat(desktop): integrate electron-liquid-glass for macOS Tahoe

Add native liquid glass visual effect on macOS 26+ (Tahoe), replacing
vibrancy with Apple's NSGlassEffectView API via electron-liquid-glass.

- Centralize all platform visual effects in WindowThemeManager
- Strip platform props from BrowserWindow options to prevent config leaking
- Remove vibrancy from appBrowsers/WindowTemplate (managed by ThemeManager)
- Add isMacTahoe detection in env.ts and preload
- Fix applyVisualEffects to handle macOS platform symmetrically

* fix(tests): add isMacTahoe detection in Browser test environment

Introduce isMacTahoe flag in the test environment to support macOS Tahoe-specific features. This change enhances the test suite's ability to simulate and validate platform-specific behavior.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(theme): update liquid glass variant and adjust background color mix for desktop themes

- Changed liquid glass variant from 2 to 15 for improved visual effects.
- Adjusted background color mix percentages for dark and light themes on desktop to enhance visual consistency.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(theme): adjust background color mix for dark theme on desktop

- Updated the background color mix percentage for the dark theme on desktop from 70% to 90% for improved visual effect consistency.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-02-14 00:31:16 +08:00
committed by GitHub
parent 2ee46b8693
commit f46916a74d
12 changed files with 146 additions and 73 deletions

View File

@@ -33,7 +33,7 @@ const isDarwin = getTargetPlatform() === 'darwin';
*/
export const nativeModules = [
// macOS-only native modules
...(isDarwin ? ['node-mac-permissions'] : []),
...(isDarwin ? ['node-mac-permissions', 'electron-liquid-glass'] : []),
'@napi-rs/canvas',
// Add more native modules here as needed
];

View File

@@ -41,6 +41,7 @@
},
"dependencies": {
"@napi-rs/canvas": "^0.1.70",
"electron-liquid-glass": "^1.1.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"fetch-socks": "^1.3.2",

View File

@@ -18,7 +18,6 @@ export const appBrowsers = {
path: '/',
showOnInit: true,
titleBarStyle: 'hidden',
vibrancy: 'under-window',
width: 1200,
},
devtools: {
@@ -31,7 +30,6 @@ export const appBrowsers = {
parentIdentifier: 'app',
path: '/desktop/devtools',
titleBarStyle: 'hiddenInset',
vibrancy: 'under-window',
width: 1000,
},
} satisfies Record<string, BrowserWindowOpts>;
@@ -39,7 +37,6 @@ export const appBrowsers = {
// Window templates for multi-instance windows
export interface WindowTemplate {
allowMultipleInstances: boolean;
// Include common BrowserWindow options
autoHideMenuBar?: boolean;
baseIdentifier: string;
basePath: string;
@@ -51,22 +48,8 @@ export interface WindowTemplate {
showOnInit?: boolean;
title?: string;
titleBarStyle?: 'hidden' | 'default' | 'hiddenInset' | 'customButtonsOnHover';
vibrancy?:
| 'appearance-based'
| 'content'
| 'fullscreen-ui'
| 'header'
| 'hud'
| 'menu'
| 'popover'
| 'selection'
| 'sheet'
| 'sidebar'
| 'titlebar'
| 'tooltip'
| 'under-page'
| 'under-window'
| 'window';
// Note: vibrancy / visualEffectState / transparent are intentionally omitted.
// Platform visual effects are managed exclusively by WindowThemeManager.
width?: number;
}
@@ -81,7 +64,6 @@ export const windowTemplates = {
minWidth: 400,
parentIdentifier: 'app',
titleBarStyle: 'hidden',
vibrancy: 'under-window',
width: 900,
},
} satisfies Record<string, WindowTemplate>;

View File

@@ -11,6 +11,15 @@ export const isMac = macOS();
export const isWindows = windows();
export const isLinux = linux();
function getIsMacTahoe(): boolean {
if (!isMac) return false;
// macOS 26 (Tahoe) corresponds to Darwin kernel 25.x
const darwinMajor = parseInt(os.release().split('.')[0], 10);
return darwinMajor >= 25;
}
export const isMacTahoe = getIsMacTahoe();
function getIsWindows11() {
if (!isWindows) return false;
// Get OS version (e.g., "10.0.22621")

View File

@@ -110,7 +110,18 @@ export default class Browser {
// ==================== Window Creation ====================
private createBrowserWindow(): BrowserWindow {
const { title, width, height, ...rest } = this.options;
const {
title,
width,
height,
// Strip platform visual effect props — these are managed exclusively
// by WindowThemeManager.getPlatformConfig() to prevent config leaking
// from appBrowsers/windowTemplates into the BrowserWindow constructor.
vibrancy: _vibrancy,
visualEffectState: _visualEffectState,
transparent: _transparent,
...rest
} = this.options;
const resolvedState = this.stateManager.resolveState({ height, width });
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
@@ -125,9 +136,6 @@ export default class Browser {
height: resolvedState.height,
show: false,
title,
vibrancy: 'sidebar',
visualEffectState: 'active',
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
@@ -138,6 +146,7 @@ export default class Browser {
width: resolvedState.width,
x: resolvedState.x,
y: resolvedState.y,
// Platform visual config is the SOLE source of vibrancy / transparency / titleBarOverlay.
...this.themeManager.getPlatformConfig(),
});
}
@@ -145,7 +154,7 @@ export default class Browser {
private setupWindow(browserWindow: BrowserWindow): void {
logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
// Setup theme management
// Setup theme management (includes liquid glass lifecycle on macOS Tahoe)
this.themeManager.attach(browserWindow);
// Setup network interceptors

View File

@@ -1,9 +1,12 @@
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme } from 'electron';
import { join } from 'node:path';
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { type BrowserWindow, type BrowserWindowConstructorOptions, nativeTheme } from 'electron';
import { buildDir } from '@/const/dir';
import { isDev, isMac, isWindows } from '@/const/env';
import { isDev, isMac, isMacTahoe, isWindows } from '@/const/env';
import { createLogger } from '@/utils/logger';
import {
BACKGROUND_DARK,
BACKGROUND_LIGHT,
@@ -11,7 +14,6 @@ import {
SYMBOL_COLOR_LIGHT,
THEME_CHANGE_DELAY,
} from '../../const/theme';
import { createLogger } from '@/utils/logger';
const logger = createLogger('core:WindowThemeManager');
@@ -26,6 +28,18 @@ interface WindowsThemeConfig {
titleBarStyle: 'hidden';
}
// Lazy-load liquid glass only on macOS Tahoe to avoid import errors on other platforms.
// Dynamic require is intentional: native .node addons cannot be loaded via
// async import() and must be synchronously required at module init time.
let liquidGlass: typeof import('electron-liquid-glass').default | undefined;
if (isMacTahoe) {
try {
liquidGlass = require('electron-liquid-glass');
} catch {
// Native module not available (e.g. wrong architecture or missing binary)
}
}
/**
* Manages window theme configuration and visual effects
*/
@@ -34,6 +48,7 @@ export class WindowThemeManager {
private browserWindow?: BrowserWindow;
private listenerSetup = false;
private boundHandleThemeChange: () => void;
private liquidGlassViewId?: number;
constructor(identifier: string) {
this.identifier = identifier;
@@ -52,12 +67,21 @@ export class WindowThemeManager {
// ==================== Lifecycle ====================
/**
* Attach to a browser window and setup theme handling
* Attach to a browser window and setup theme handling.
* Owns the full visual effect lifecycle including liquid glass on macOS Tahoe.
*/
attach(browserWindow: BrowserWindow): void {
this.browserWindow = browserWindow;
this.setupThemeListener();
this.applyVisualEffects();
// Liquid glass must be applied after window content loads (native view needs
// a rendered surface). The effect persists across subsequent in-window navigations.
if (this.useLiquidGlass) {
browserWindow.webContents.once('did-finish-load', () => {
this.applyLiquidGlass();
});
}
}
/**
@@ -69,6 +93,7 @@ export class WindowThemeManager {
this.listenerSetup = false;
logger.debug(`[${this.identifier}] Theme listener cleaned up.`);
}
this.liquidGlassViewId = undefined;
this.browserWindow = undefined;
}
@@ -81,6 +106,13 @@ export class WindowThemeManager {
return nativeTheme.shouldUseDarkColors;
}
/**
* Whether liquid glass is available and should be used
*/
get useLiquidGlass(): boolean {
return isMacTahoe && !!liquidGlass;
}
/**
* Get platform-specific theme configuration for window creation
*/
@@ -92,8 +124,19 @@ export class WindowThemeManager {
// Calculate traffic light position to center vertically in title bar
// Traffic light buttons are approximately 12px tall
const trafficLightY = Math.round((TITLE_BAR_HEIGHT - 12) / 2);
if (this.useLiquidGlass) {
// Liquid glass requires transparent window and must NOT use vibrancy — they conflict.
return {
trafficLightPosition: { x: 12, y: trafficLightY },
transparent: true,
};
}
return {
trafficLightPosition: { x: 12, y: trafficLightY },
vibrancy: 'sidebar',
visualEffectState: 'active',
};
}
return {};
@@ -135,58 +178,37 @@ export class WindowThemeManager {
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
setTimeout(() => {
this.applyVisualEffects();
this.applyWindowsTitleBarOverlay();
}, THEME_CHANGE_DELAY);
}
// ==================== Visual Effects ====================
private resolveWindowsIsDarkModeFromElectron(): boolean {
/**
* Resolve dark mode from Electron theme source for runtime visual effect updates.
* Checks explicit themeSource first to handle app-level theme overrides correctly.
*/
private resolveIsDarkMode(): boolean {
if (nativeTheme.themeSource === 'dark') return true;
if (nativeTheme.themeSource === 'light') return false;
return nativeTheme.shouldUseDarkColors;
}
/**
* Apply Windows title bar overlay based on Electron theme mode.
* Mirror the structure of `applyVisualEffects`, but only updates title bar overlay.
*/
private applyWindowsTitleBarOverlay(): void {
if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
logger.debug(`[${this.identifier}] Applying Windows title bar overlay`);
const isDarkMode = this.resolveWindowsIsDarkModeFromElectron();
try {
if (!isWindows) return;
this.browserWindow.setTitleBarOverlay(this.getWindowsTitleBarOverlay(isDarkMode));
logger.debug(
`[${this.identifier}] Windows title bar overlay applied successfully (dark mode: ${isDarkMode})`,
);
} catch (error) {
logger.error(`[${this.identifier}] Failed to apply Windows title bar overlay:`, error);
}
}
/**
* Apply visual effects based on current theme
* Apply visual effects based on current theme.
* Single entry point for ALL platform visual effects.
*/
applyVisualEffects(): void {
if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
logger.debug(`[${this.identifier}] Applying visual effects for platform`);
const isDarkMode = this.isDarkMode;
const isDarkMode = this.resolveIsDarkMode();
logger.debug(`[${this.identifier}] Applying visual effects (dark: ${isDarkMode})`);
try {
if (isWindows) {
this.applyWindowsVisualEffects(isDarkMode);
} else if (isMac) {
this.applyMacVisualEffects();
}
logger.debug(
`[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
);
} catch (error) {
logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
}
@@ -207,4 +229,44 @@ export class WindowThemeManager {
this.browserWindow.setBackgroundColor(config.backgroundColor);
this.browserWindow.setTitleBarOverlay(config.titleBarOverlay);
}
/**
* Apply macOS visual effects.
* - Tahoe+: liquid glass auto-adapts to dark mode; ensure it's applied if not yet.
* - Pre-Tahoe: vibrancy is managed natively by Electron, no runtime action needed.
*/
private applyMacVisualEffects(): void {
if (!this.browserWindow) return;
if (this.useLiquidGlass) {
// Attempt apply if not yet done (e.g. initial load failed, or window recreated)
this.applyLiquidGlass();
}
}
// ==================== Liquid Glass ====================
/**
* Apply liquid glass native view to the window.
* Idempotent — guards against double-application via `liquidGlassViewId`.
*/
applyLiquidGlass(): void {
if (!this.useLiquidGlass || !liquidGlass) return;
if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
if (this.liquidGlassViewId !== undefined) return;
try {
// Ensure traffic light buttons remain visible with transparent window
this.browserWindow.setWindowButtonVisibility(true);
const handle = this.browserWindow.getNativeWindowHandle();
this.liquidGlassViewId = liquidGlass.addView(handle);
liquidGlass.unstable_setVariant(this.liquidGlassViewId, 15);
logger.info(`[${this.identifier}] Liquid glass applied (viewId: ${this.liquidGlassViewId})`);
} catch (error) {
logger.error(`[${this.identifier}] Failed to apply liquid glass:`, error);
}
}
}

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App as AppCore } from '../../App';
import Browser, { BrowserWindowOpts } from '../Browser';
import { type App as AppCore } from '../../App';
import Browser, { type BrowserWindowOpts } from '../Browser';
// Use vi.hoisted to define mocks before hoisting
const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowserWindow } =
@@ -100,6 +100,7 @@ vi.mock('@/const/dir', () => ({
vi.mock('@/const/env', () => ({
isDev: false,
isMac: false,
isMacTahoe: false,
isWindows: true,
}));
@@ -605,9 +606,9 @@ describe('Browser', () => {
const keepAliveBrowser = new Browser(keepAliveOptions, mockApp);
// Get the new close handler
const keepAliveCloseHandler = mockBrowserWindow.on.mock.calls
.filter((call) => call[0] === 'close')
.pop()?.[1];
const keepAliveCloseHandler = mockBrowserWindow.on.mock.calls.findLast(
(call) => call[0] === 'close',
)?.[1];
const mockEvent = { preventDefault: vi.fn() };
keepAliveCloseHandler(mockEvent);

View File

@@ -13,6 +13,7 @@ const { mockNativeTheme, mockBrowserWindow } = vi.hoisted(() => ({
off: vi.fn(),
on: vi.fn(),
shouldUseDarkColors: false,
themeSource: 'system' as string,
},
}));
@@ -35,6 +36,8 @@ vi.mock('@/const/dir', () => ({
vi.mock('@/const/env', () => ({
isDev: false,
isMac: false,
isMacTahoe: false,
isWindows: true,
}));
@@ -58,6 +61,7 @@ describe('WindowThemeManager', () => {
vi.useFakeTimers();
mockNativeTheme.shouldUseDarkColors = false;
mockNativeTheme.themeSource = 'system';
mockBrowserWindow.isDestroyed.mockReturnValue(false);
manager = new WindowThemeManager('test-window');

View File

@@ -51,7 +51,7 @@ describe('setupElectronApi', () => {
});
});
it('should expose lobeEnv with darwinMajorVersion', () => {
it('should expose lobeEnv with darwinMajorVersion and isMacTahoe', () => {
setupElectronApi();
const call = mockContextBridgeExposeInMainWorld.mock.calls.find((i) => i[0] === 'lobeEnv');
@@ -63,6 +63,9 @@ describe('setupElectronApi', () => {
exposedEnv.darwinMajorVersion === undefined ||
typeof exposedEnv.darwinMajorVersion === 'number',
).toBe(true);
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'isMacTahoe')).toBe(true);
expect(typeof exposedEnv.isMacTahoe).toBe('boolean');
});
it('should expose both APIs in correct order', () => {

View File

@@ -22,9 +22,10 @@ export const setupElectronApi = () => {
const os = require('node:os');
const osInfo = os.release();
const darwinMajorVersion = osInfo.split('.')[0];
const darwinMajorVersion = Number(osInfo.split('.')[0]);
contextBridge.exposeInMainWorld('lobeEnv', {
darwinMajorVersion: Number(darwinMajorVersion),
darwinMajorVersion,
isMacTahoe: process.platform === 'darwin' && darwinMajorVersion >= 25,
});
};