feat(desktop): Linux window specialization (#13059)

*  feat(desktop): Linux window specialization

- Add minimize/maximize/close buttons for Linux (WinControl)
- Linux: no tray, close main window quits app
- Linux: native window shadow and opaque background
- i18n for window control tooltips

Made-with: Cursor

* 🌐 i18n: add window control translations for all locales

Made-with: Cursor

* 🐛 fix(desktop): show WinControl in SimpleTitleBar only on Linux

Made-with: Cursor

* 🐛 fix(desktop): limit custom titlebar controls to Linux

Avoid rendering duplicate window controls on Windows and keep the Linux maximize button in sync with the current window state.

Made-with: Cursor

---------

Co-authored-by: LiJian <onlyyoulove3@gmail.com>
This commit is contained in:
Innei
2026-03-17 16:59:33 +08:00
committed by GitHub
parent 26269eacbb
commit 8f7527b7e2
31 changed files with 310 additions and 59 deletions

View File

@@ -27,7 +27,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
? { tab: typeof options === 'string' ? options : undefined }
: options;
console.log('[BrowserWindowsCtr] Received request to open settings', normalizedOptions);
console.info('[BrowserWindowsCtr] Received request to open settings', normalizedOptions);
try {
let fullPath: string;
@@ -73,6 +73,13 @@ export default class BrowserWindowsCtr extends ControllerModule {
});
}
@IpcMethod()
isWindowMaximized() {
return this.withSenderIdentifier((identifier) => {
return this.app.browserManager.isWindowMaximized(identifier);
});
}
@IpcMethod()
setWindowSize(params: WindowSizeParams) {
this.withSenderIdentifier((identifier) => {
@@ -106,7 +113,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
@IpcMethod()
async interceptRoute(params: InterceptRouteParams) {
const { path, source } = params;
console.log(
console.info(
`[BrowserWindowsCtr] Received route interception request: ${path}, source: ${source}`,
);
@@ -115,11 +122,11 @@ export default class BrowserWindowsCtr extends ControllerModule {
// If no matching route found, return not intercepted
if (!matchedRoute) {
console.log(`[BrowserWindowsCtr] No matching route configuration found: ${path}`);
console.info(`[BrowserWindowsCtr] No matching route configuration found: ${path}`);
return { intercepted: false, path, source };
}
console.log(
console.info(
`[BrowserWindowsCtr] Intercepted route: ${path}, target window: ${matchedRoute.targetWindow}`,
);
@@ -153,7 +160,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
uniqueId?: string;
}) {
try {
console.log('[BrowserWindowsCtr] Creating multi-instance window:', params);
console.info('[BrowserWindowsCtr] Creating multi-instance window:', params);
const result = this.app.browserManager.createMultiInstanceWindow(
params.templateId,
@@ -223,11 +230,11 @@ export default class BrowserWindowsCtr extends ControllerModule {
browser.show();
}
private withSenderIdentifier(fn: (identifier: string) => void) {
private withSenderIdentifier<T>(fn: (identifier: string) => T): T | undefined {
const context = getIpcContext();
if (!context) return;
if (!context) return undefined;
const identifier = this.app.browserManager.getIdentifierByWebContents(context.sender);
if (!identifier) return;
fn(identifier);
if (!identifier) return undefined;
return fn(identifier);
}
}

View File

@@ -1,8 +1,8 @@
import type { InterceptRouteParams } from '@lobechat/electron-client-ipc';
import type { Mock} from 'vitest';
import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppBrowsersIdentifiers} from '@/appBrowsers';
import type { AppBrowsersIdentifiers } from '@/appBrowsers';
import type { App } from '@/core/App';
import type { IpcContext } from '@/utils/ipc';
import { runWithIpcContext } from '@/utils/ipc';
@@ -28,6 +28,7 @@ const mockRedirectToPage = vi.fn();
const mockCloseWindow = vi.fn();
const mockMinimizeWindow = vi.fn();
const mockMaximizeWindow = vi.fn();
const mockIsWindowMaximized = vi.fn();
const mockRetrieveByIdentifier = vi.fn();
const testSenderIdentifierString: string = 'test-window-event-id';
@@ -55,6 +56,7 @@ const mockApp = {
closeWindow: mockCloseWindow,
minimizeWindow: mockMinimizeWindow,
maximizeWindow: mockMaximizeWindow,
isWindowMaximized: mockIsWindowMaximized,
retrieveByIdentifier: mockRetrieveByIdentifier.mockImplementation(
(identifier: AppBrowsersIdentifiers | string) => {
if (identifier === 'some-other-window') {
@@ -135,6 +137,20 @@ describe('BrowserWindowsCtr', () => {
});
});
describe('isWindowMaximized', () => {
it('should return maximized state for the sender window', () => {
mockIsWindowMaximized.mockReturnValueOnce(true);
const sender = {} as any;
const context = { sender, event: { sender } as any } as IpcContext;
const result = runWithIpcContext(context, () => browserWindowsCtr.isWindowMaximized());
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
expect(mockIsWindowMaximized).toHaveBeenCalledWith(testSenderIdentifierString);
expect(result).toBe(true);
});
});
describe('interceptRoute', () => {
const baseParams = { source: 'link-click' as const };

View File

@@ -250,8 +250,8 @@ export class App {
this.isQuiting = false;
app.on('window-all-closed', () => {
if (windows()) {
logger.info('All windows closed, quitting application (Windows)');
if (windows() || process.platform === 'linux') {
logger.info(`All windows closed, quitting application (${process.platform})`);
app.quit();
}
});

View File

@@ -1,17 +1,12 @@
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type { WebContents } from 'electron';
import { isLinux } from '@/const/env';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { createLogger } from '@/utils/logger';
import type {
AppBrowsersIdentifiers,
WindowTemplateIdentifiers} from '../../appBrowsers';
import {
appBrowsers,
BrowsersIdentifiers,
windowTemplates,
} from '../../appBrowsers';
import type { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '../../appBrowsers';
import { appBrowsers, BrowsersIdentifiers, windowTemplates } from '../../appBrowsers';
import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
@@ -196,11 +191,15 @@ export class BrowserManager {
// Dynamically determine initial path for main window
if (browser.identifier === BrowsersIdentifiers.app) {
const initialPath = isOnboardingCompleted ? '/' : '/desktop-onboarding';
browser = { ...browser, path: initialPath };
browser = {
...browser,
keepAlive: isLinux ? false : browser.keepAlive,
path: initialPath,
};
logger.debug(`Main window initial path: ${initialPath}`);
}
if (browser.keepAlive) {
if (browser.keepAlive || browser.identifier === BrowsersIdentifiers.app) {
this.retrieveOrInitialize(browser);
}
});
@@ -259,6 +258,11 @@ export class BrowserManager {
}
}
isWindowMaximized(identifier: string) {
const browser = this.browsers.get(identifier);
return browser?.browserWindow.isMaximized() ?? false;
}
setWindowSize(identifier: string, size: { height?: number; width?: number }) {
const browser = this.browsers.get(identifier);
browser?.setWindowSize(size);

View File

@@ -4,7 +4,7 @@ import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { type BrowserWindow, type BrowserWindowConstructorOptions, nativeTheme } from 'electron';
import { buildDir } from '@/const/dir';
import { isDev, isMac, isMacTahoe, isWindows } from '@/const/env';
import { isDev, isLinux, isMac, isMacTahoe, isWindows } from '@/const/env';
import { createLogger } from '@/utils/logger';
import {
@@ -28,9 +28,15 @@ interface WindowsThemeConfig {
titleBarStyle: 'hidden';
}
interface LinuxThemeConfig {
backgroundColor: string;
hasShadow: true;
}
// 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.
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- dynamic require, type from module
let liquidGlass: typeof import('electron-liquid-glass').default | undefined;
if (isMacTahoe) {
try {
@@ -139,6 +145,9 @@ export class WindowThemeManager {
visualEffectState: 'active',
};
}
if (isLinux) {
return this.getLinuxConfig();
}
return {};
}
@@ -154,6 +163,13 @@ export class WindowThemeManager {
};
}
private getLinuxConfig(): LinuxThemeConfig {
return {
backgroundColor: this.resolveIsDarkMode() ? BACKGROUND_DARK : BACKGROUND_LIGHT,
hasShadow: true,
};
}
// ==================== Theme Listener ====================
private setupThemeListener(): void {
@@ -206,6 +222,8 @@ export class WindowThemeManager {
try {
if (isWindows) {
this.applyWindowsVisualEffects(isDarkMode);
} else if (isLinux) {
this.applyLinuxVisualEffects();
} else if (isMac) {
this.applyMacVisualEffects();
}
@@ -230,6 +248,18 @@ export class WindowThemeManager {
this.browserWindow.setTitleBarOverlay(config.titleBarOverlay);
}
private applyLinuxVisualEffects(): void {
if (!this.browserWindow) return;
const config = this.getLinuxConfig();
const browserWindow = this.browserWindow as BrowserWindow & {
setHasShadow?: (hasShadow: boolean) => void;
};
browserWindow.setBackgroundColor(config.backgroundColor);
browserWindow.setHasShadow?.(true);
}
/**
* Apply macOS visual effects.
* - Tahoe+: liquid glass auto-adapts to dark mode; ensure it's applied if not yet.

View File

@@ -99,6 +99,7 @@ vi.mock('@/const/dir', () => ({
vi.mock('@/const/env', () => ({
isDev: false,
isLinux: false,
isMac: false,
isMacTahoe: false,
isWindows: true,
@@ -240,7 +241,7 @@ describe('Browser', () => {
});
// Create new browser to trigger initialization with saved state
const newBrowser = new Browser(defaultOptions, mockApp);
const _newBrowser = new Browser(defaultOptions, mockApp);
expect(MockBrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
@@ -328,7 +329,7 @@ describe('Browser', () => {
mockNativeTheme.shouldUseDarkColors = true;
// Create browser with dark mode
const darkBrowser = new Browser(defaultOptions, mockApp);
const _darkBrowser = new Browser(defaultOptions, mockApp);
expect(MockBrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
@@ -603,7 +604,7 @@ describe('Browser', () => {
...defaultOptions,
keepAlive: true,
};
const keepAliveBrowser = new Browser(keepAliveOptions, mockApp);
const _keepAliveBrowser = new Browser(keepAliveOptions, mockApp);
// Get the new close handler
const keepAliveCloseHandler = mockBrowserWindow.on.mock.calls.findLast(

View File

@@ -88,6 +88,10 @@ vi.mock('@/controllers/RemoteServerConfigCtr', () => ({
},
}));
vi.mock('@/const/env', () => ({
isLinux: false,
}));
describe('BrowserManager', () => {
let manager: BrowserManager;
let mockApp: AppCore;
@@ -394,6 +398,24 @@ describe('BrowserManager', () => {
expect(browser?.browserWindow.maximize).not.toHaveBeenCalled();
});
});
describe('isWindowMaximized', () => {
it('should return false when window is not maximized', () => {
manager.retrieveByIdentifier('app');
const browser = manager.browsers.get('app');
browser!.browserWindow.isMaximized = vi.fn().mockReturnValue(false);
expect(manager.isWindowMaximized('app')).toBe(false);
});
it('should return true when window is maximized', () => {
manager.retrieveByIdentifier('app');
const browser = manager.browsers.get('app');
browser!.browserWindow.isMaximized = vi.fn().mockReturnValue(true);
expect(manager.isWindowMaximized('app')).toBe(true);
});
});
});
describe('getIdentifierByWebContents', () => {

View File

@@ -36,6 +36,7 @@ vi.mock('@/const/dir', () => ({
vi.mock('@/const/env', () => ({
isDev: false,
isLinux: false,
isMac: false,
isMacTahoe: false,
isWindows: true,