mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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};
|
||||
`,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
31
src/features/ElectronTitlebar/SimpleTitleBar.tsx
Normal file
31
src/features/ElectronTitlebar/SimpleTitleBar.tsx
Normal 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;
|
||||
@@ -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
2
src/utils/platform.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export platform utilities from packages/utils
|
||||
export * from '../../packages/utils/src/platform';
|
||||
Reference in New Issue
Block a user