feat(electron): add custom titlebar for Electron windows (#11438)

- Add SimpleTitleBar component for secondary windows (onboarding, settings)
- Configure traffic light position for macOS native window controls
- Enhance isMacOSWithLargeWindowBorders to support Electron environment
- Add getDarwinMajorVersion utility for version detection
- Integrate SimpleTitleBar into desktop onboarding layout
- Re-export platform utilities from packages/utils for better accessibility
This commit is contained in:
Innei
2026-01-12 17:37:50 +08:00
committed by GitHub
parent 4b9d32d993
commit 08f6ee3d83
9 changed files with 116 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ import { join } from 'node:path';
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import { TITLE_BAR_HEIGHT } from '@/const/theme';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
import { setResponseHeader } from '@/utils/http-headers';
@@ -119,6 +120,10 @@ export default class Browser {
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
// 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);
return new BrowserWindow({
...rest,
autoHideMenuBar: true,
@@ -128,6 +133,7 @@ export default class Browser {
height: resolvedState.height,
show: false,
title,
trafficLightPosition: isMac ? { x: 12, y: trafficLightY } : undefined,
vibrancy: 'sidebar',
visualEffectState: 'active',
webPreferences: {

View File

@@ -10,6 +10,7 @@ export * from './merge';
export * from './multimodalContent';
export * from './number';
export * from './object';
export * from './platform';
export * from './pricing';
export * from './safeParseJSON';
export * from './sleep';

View File

@@ -25,18 +25,50 @@ export const browserInfo = {
export const isMacOS = () => getPlatform() === 'Mac OS';
/**
* Get macOS Darwin major version number
* @returns Darwin major version (e.g., 25, 26) or 0 if not available
*/
export const getDarwinMajorVersion = (): number => {
if (isOnServerSide || typeof window === 'undefined') return 0;
// In Electron environment, use window.lobeEnv.darwinMajorVersion if available
if (typeof (window as any)?.lobeEnv?.darwinMajorVersion === 'number') {
return (window as any).lobeEnv.darwinMajorVersion;
}
// In web environment, try to parse from userAgent
if (typeof navigator !== 'undefined') {
const match = navigator.userAgent.match(/Mac OS X (\d+)[._](\d+)/);
if (match) {
return parseInt(match[1], 10);
}
}
return 0;
};
/**
*
* We can't use it to detect the macOS real version, and we also don't know if it's macOS 26, only an estimated value.
* @returns true if the current browser is macOS and the version is 10.15 or later
* @returns true if the current browser is macOS and the version is 10.15 or later (web) or darwinMajorVersion >= 25 (Electron)
*/
export const isMacOSWithLargeWindowBorders = () => {
if (isOnServerSide || typeof navigator === 'undefined') return false;
// keep consistent with the original logic: only for macOS on web (exclude Electron)
// Check if we're in Electron environment
const isElectron =
/Electron\//.test(navigator.userAgent) || Boolean((window as any)?.process?.type);
if (isElectron || !isMacOS()) return false;
// In Electron environment, check darwinMajorVersion from window.lobeEnv
if (isElectron) {
const darwinMajorVersion = getDarwinMajorVersion();
// macOS 25+ has large window borders
return darwinMajorVersion >= 25;
}
// keep consistent with the original logic: only for macOS on web (exclude Electron)
if (!isMacOS()) return false;
const match = navigator.userAgent.match(/Mac OS X (\d+)[._](\d+)/);
if (!match) return false;

View File

@@ -5,6 +5,7 @@ import { Divider } from 'antd';
import { cx } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import { SimpleTitleBar, TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
import LangButton from '@/features/User/UserPanel/LangButton';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useIsDark } from '@/hooks/useIsDark';
@@ -14,36 +15,43 @@ import { styles } from './style';
const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
const isDarkMode = useIsDark();
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<Flexbox height={'100%'} width={'100%'}>
<SimpleTitleBar />
<Flexbox
className={cx(isDarkMode ? styles.innerContainerDark : styles.innerContainerLight)}
height={'100%'}
className={styles.outerContainer}
height={`calc(100% - ${TITLE_BAR_HEIGHT}px)`}
style={{ paddingBottom: 8, paddingInline: 8 }}
width={'100%'}
>
<Flexbox
align={'center'}
className={cx(styles.drag)}
gap={8}
horizontal
justify={'space-between'}
padding={16}
className={cx(isDarkMode ? styles.innerContainerDark : styles.innerContainerLight)}
height={'100%'}
width={'100%'}
>
<div />
<Flexbox align={'center'} horizontal>
<LangButton placement={'bottomRight'} size={18} />
<Divider className={styles.divider} orientation={'vertical'} />
<ThemeButton placement={'bottomRight'} size={18} />
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
padding={16}
width={'100%'}
>
<div />
<Flexbox align={'center'} horizontal>
<LangButton placement={'bottomRight'} size={18} />
<Divider className={styles.divider} orientation={'vertical'} />
<ThemeButton placement={'bottomRight'} size={18} />
</Flexbox>
</Flexbox>
<Center height={'100%'} padding={16} width={'100%'}>
{children}
</Center>
<Center padding={24}>
<Text align={'center'} type={'secondary'}>
© 2025 LobeHub. All rights reserved.
</Text>
</Center>
</Flexbox>
<Center height={'100%'} padding={16} width={'100%'}>
{children}
</Center>
<Center padding={24}>
<Text align={'center'} type={'secondary'}>
© 2025 LobeHub. All rights reserved.
</Text>
</Center>
</Flexbox>
</Flexbox>
);

View File

@@ -1,14 +1,13 @@
import { createStaticStyles } from 'antd-style';
import { isMacOSWithLargeWindowBorders } from '@/utils/platform';
export const styles = createStaticStyles(({ css, cssVar }) => ({
// Divider 样式
divider: css`
height: 24px;
`,
drag: css`
-webkit-app-region: drag;
`,
// 内层容器 - 深色模式
innerContainerDark: css`
position: relative;
@@ -16,7 +15,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
overflow: hidden;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadius};
border-radius: ${!isMacOSWithLargeWindowBorders()
? cssVar.borderRadius
: `${cssVar.borderRadius} 12px ${cssVar.borderRadius} 12px`};
background: ${cssVar.colorBgContainer};
`,
@@ -28,7 +29,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
overflow: hidden;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius};
border-radius: ${!isMacOSWithLargeWindowBorders()
? cssVar.borderRadius
: `${cssVar.borderRadius} 12px ${cssVar.borderRadius} 12px`};
background: ${cssVar.colorBgContainer};
`,

View File

@@ -6,7 +6,7 @@ import { isDesktop } from '@/const/version';
import { useIsDark } from '@/hooks/useIsDark';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { isMacOSWithLargeWindowBorders } from '@/utils/platform';
import { getDarwinMajorVersion, isMacOSWithLargeWindowBorders } from '@/utils/platform';
import { styles } from './DesktopLayoutContainer/style';
@@ -24,8 +24,7 @@ const DesktopLayoutContainer: FC<PropsWithChildren> = ({ children }) => {
);
const innerCssVariables = useMemo<Record<string, string>>(() => {
const darwinMajorVersion =
typeof window !== 'undefined' ? (window.lobeEnv?.darwinMajorVersion ?? 0) : 0;
const darwinMajorVersion = getDarwinMajorVersion();
const borderRadius = darwinMajorVersion >= 25 ? '12px' : cssVar.borderRadius;
const borderBottomRightRadius =

View File

@@ -0,0 +1,31 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { type FC } from 'react';
import { ProductLogo } from '@/components/Branding/ProductLogo';
import { electronStylish } from '@/styles/electron';
import { TITLE_BAR_HEIGHT } from './const';
/**
* A simple, minimal TitleBar for Electron windows.
* Provides draggable area without business logic (navigation, updates, etc.)
* Use this for secondary windows like onboarding, settings, etc.
*/
const SimpleTitleBar: FC = () => {
return (
<Flexbox
align={'center'}
className={electronStylish.draggable}
height={TITLE_BAR_HEIGHT}
horizontal
justify={'center'}
width={'100%'}
>
<ProductLogo size={16} type={'text'} />
</Flexbox>
);
};
export default SimpleTitleBar;

View File

@@ -67,3 +67,4 @@ const TitleBar = memo(() => {
export default TitleBar;
export { TITLE_BAR_HEIGHT } from './const';
export { default as SimpleTitleBar } from './SimpleTitleBar';

2
src/utils/platform.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export platform utilities from packages/utils
export * from '../../packages/utils/src/platform';