mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -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
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user