From 8f7527b7e2627c96ae9fc040eb6ce78841fcbf6a Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 17 Mar 2026 16:59:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(desktop):=20Linux=20window=20s?= =?UTF-8?q?pecialization=20(#13059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --- .../src/main/controllers/BrowserWindowsCtr.ts | 25 ++-- .../__tests__/BrowserWindowsCtr.test.ts | 20 +++- apps/desktop/src/main/core/App.ts | 4 +- .../src/main/core/browser/BrowserManager.ts | 24 ++-- .../main/core/browser/WindowThemeManager.ts | 32 ++++- .../core/browser/__tests__/Browser.test.ts | 7 +- .../browser/__tests__/BrowserManager.test.ts | 22 ++++ .../__tests__/WindowThemeManager.test.ts | 1 + locales/ar/electron.json | 5 +- locales/bg-BG/electron.json | 5 +- locales/de-DE/electron.json | 5 +- locales/en-US/electron.json | 5 +- locales/es-ES/electron.json | 5 +- locales/fa-IR/electron.json | 5 +- locales/fr-FR/electron.json | 5 +- locales/it-IT/electron.json | 5 +- locales/ja-JP/electron.json | 5 +- locales/ko-KR/electron.json | 5 +- locales/nl-NL/electron.json | 5 +- locales/pl-PL/electron.json | 5 +- locales/pt-BR/electron.json | 5 +- locales/ru-RU/electron.json | 5 +- locales/tr-TR/electron.json | 5 +- locales/vi-VN/electron.json | 5 +- locales/zh-CN/electron.json | 5 +- locales/zh-TW/electron.json | 5 +- .../Electron/titlebar/SimpleTitleBar.tsx | 13 ++- src/features/Electron/titlebar/TitleBar.tsx | 14 +-- src/features/Electron/titlebar/WinControl.tsx | 109 +++++++++++++++++- src/locales/default/electron.ts | 4 + src/services/electron/system.ts | 4 + 31 files changed, 310 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts index 6de0a6e7ff..853306c20d 100644 --- a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +++ b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts @@ -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(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); } } diff --git a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts index f38a1241d4..59a86f84aa 100644 --- a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts @@ -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 }; diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts index e87a2f9b55..460b92a842 100644 --- a/apps/desktop/src/main/core/App.ts +++ b/apps/desktop/src/main/core/App.ts @@ -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(); } }); diff --git a/apps/desktop/src/main/core/browser/BrowserManager.ts b/apps/desktop/src/main/core/browser/BrowserManager.ts index d20831ab2c..16e732f6fa 100644 --- a/apps/desktop/src/main/core/browser/BrowserManager.ts +++ b/apps/desktop/src/main/core/browser/BrowserManager.ts @@ -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); diff --git a/apps/desktop/src/main/core/browser/WindowThemeManager.ts b/apps/desktop/src/main/core/browser/WindowThemeManager.ts index 4a6ad3a39c..df3447186b 100644 --- a/apps/desktop/src/main/core/browser/WindowThemeManager.ts +++ b/apps/desktop/src/main/core/browser/WindowThemeManager.ts @@ -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. diff --git a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts index 3738018430..ae221d70c2 100644 --- a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts @@ -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( diff --git a/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts b/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts index 7668eff2df..e243d81845 100644 --- a/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts @@ -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', () => { diff --git a/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts b/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts index c554485cb7..2e1065a1a5 100644 --- a/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts @@ -36,6 +36,7 @@ vi.mock('@/const/dir', () => ({ vi.mock('@/const/env', () => ({ isDev: false, + isLinux: false, isMac: false, isMacTahoe: false, isWindows: true, diff --git a/locales/ar/electron.json b/locales/ar/electron.json index ec85940941..436ad95703 100644 --- a/locales/ar/electron.json +++ b/locales/ar/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "فشل الاتصال بالتفويض", "waitingOAuth.helpText": "إذا لم يفتح المتصفح تلقائياً، يرجى النقر على إلغاء والمحاولة مرة أخرى", "waitingOAuth.retry": "إعادة المحاولة", - "waitingOAuth.title": "في انتظار الاتصال بالتفويض" + "waitingOAuth.title": "في انتظار الاتصال بالتفويض", + "window.close": "إغلاق النافذة", + "window.maximize": "تكبير النافذة", + "window.minimize": "تصغير النافذة" } diff --git a/locales/bg-BG/electron.json b/locales/bg-BG/electron.json index f8b7096d0a..976a17f0db 100644 --- a/locales/bg-BG/electron.json +++ b/locales/bg-BG/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Неуспешна връзка за удостоверяване", "waitingOAuth.helpText": "Ако браузърът не се е отворил автоматично, моля натиснете отказ и опитайте отново", "waitingOAuth.retry": "Опитай отново", - "waitingOAuth.title": "Изчакване на връзка за удостоверяване" + "waitingOAuth.title": "Изчакване на връзка за удостоверяване", + "window.close": "Затвори прозореца", + "window.maximize": "Максимизирай прозореца", + "window.minimize": "Минимизирай прозореца" } diff --git a/locales/de-DE/electron.json b/locales/de-DE/electron.json index 9be3534ea7..e9402c949a 100644 --- a/locales/de-DE/electron.json +++ b/locales/de-DE/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Autorisierungsverbindung fehlgeschlagen", "waitingOAuth.helpText": "Falls sich der Browser nicht automatisch geöffnet hat, klicken Sie bitte auf Abbrechen und versuchen Sie es erneut", "waitingOAuth.retry": "Erneut versuchen", - "waitingOAuth.title": "Warten auf Autorisierungsverbindung" + "waitingOAuth.title": "Warten auf Autorisierungsverbindung", + "window.close": "Fenster schließen", + "window.maximize": "Fenster maximieren", + "window.minimize": "Fenster minimieren" } diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index c8970a89cb..f2924b7f44 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Authorization Connection Failed", "waitingOAuth.helpText": "If the browser did not open automatically, please click cancel and try again", "waitingOAuth.retry": "Retry", - "waitingOAuth.title": "Waiting for Authorization Connection" + "waitingOAuth.title": "Waiting for Authorization Connection", + "window.close": "Close window", + "window.maximize": "Maximize window", + "window.minimize": "Minimize window" } diff --git a/locales/es-ES/electron.json b/locales/es-ES/electron.json index 9afbb92648..92c2a34a39 100644 --- a/locales/es-ES/electron.json +++ b/locales/es-ES/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Fallo en la conexión de autorización", "waitingOAuth.helpText": "Si el navegador no se abrió automáticamente, por favor haz clic en cancelar e inténtalo de nuevo", "waitingOAuth.retry": "Reintentar", - "waitingOAuth.title": "Esperando conexión de autorización" + "waitingOAuth.title": "Esperando conexión de autorización", + "window.close": "Cerrar ventana", + "window.maximize": "Maximizar ventana", + "window.minimize": "Minimizar ventana" } diff --git a/locales/fa-IR/electron.json b/locales/fa-IR/electron.json index d91e744ab2..0f77170db0 100644 --- a/locales/fa-IR/electron.json +++ b/locales/fa-IR/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "اتصال احراز هویت ناموفق بود", "waitingOAuth.helpText": "اگر مرورگر به‌طور خودکار باز نشد، لطفاً لغو را بزنید و دوباره تلاش کنید", "waitingOAuth.retry": "تلاش مجدد", - "waitingOAuth.title": "در انتظار اتصال احراز هویت" + "waitingOAuth.title": "در انتظار اتصال احراز هویت", + "window.close": "بستن پنجره", + "window.maximize": "حداکثر کردن پنجره", + "window.minimize": "حداقل کردن پنجره" } diff --git a/locales/fr-FR/electron.json b/locales/fr-FR/electron.json index 02e22a400a..9782a86ec1 100644 --- a/locales/fr-FR/electron.json +++ b/locales/fr-FR/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Échec de la connexion d'autorisation", "waitingOAuth.helpText": "Si le navigateur ne s'est pas ouvert automatiquement, veuillez cliquer sur annuler et réessayer", "waitingOAuth.retry": "Réessayer", - "waitingOAuth.title": "En attente de la connexion d'autorisation" + "waitingOAuth.title": "En attente de la connexion d'autorisation", + "window.close": "Fermer la fenêtre", + "window.maximize": "Maximiser la fenêtre", + "window.minimize": "Minimiser la fenêtre" } diff --git a/locales/it-IT/electron.json b/locales/it-IT/electron.json index 93002d3200..f130c8c822 100644 --- a/locales/it-IT/electron.json +++ b/locales/it-IT/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Connessione di autorizzazione fallita", "waitingOAuth.helpText": "Se il browser non si è aperto automaticamente, fai clic su annulla e riprova", "waitingOAuth.retry": "Riprova", - "waitingOAuth.title": "In attesa della connessione di autorizzazione" + "waitingOAuth.title": "In attesa della connessione di autorizzazione", + "window.close": "Chiudi finestra", + "window.maximize": "Massimizza finestra", + "window.minimize": "Minimizza finestra" } diff --git a/locales/ja-JP/electron.json b/locales/ja-JP/electron.json index c78bede706..9caa761674 100644 --- a/locales/ja-JP/electron.json +++ b/locales/ja-JP/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "認証接続に失敗しました", "waitingOAuth.helpText": "ブラウザが自動的に開かない場合は、キャンセルをクリックして再試行してください", "waitingOAuth.retry": "再試行", - "waitingOAuth.title": "認証接続を待機中" + "waitingOAuth.title": "認証接続を待機中", + "window.close": "ウィンドウを閉じる", + "window.maximize": "ウィンドウを最大化", + "window.minimize": "ウィンドウを最小化" } diff --git a/locales/ko-KR/electron.json b/locales/ko-KR/electron.json index 17f77f6bf8..9bacd8848c 100644 --- a/locales/ko-KR/electron.json +++ b/locales/ko-KR/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "권한 연결 실패", "waitingOAuth.helpText": "브라우저가 자동으로 열리지 않으면, 취소를 클릭한 후 다시 시도하세요.", "waitingOAuth.retry": "다시 시도", - "waitingOAuth.title": "인증 연결 대기 중" + "waitingOAuth.title": "인증 연결 대기 중", + "window.close": "창 닫기", + "window.maximize": "창 최대화", + "window.minimize": "창 최소화" } diff --git a/locales/nl-NL/electron.json b/locales/nl-NL/electron.json index 0d2ec371d6..b7e2659fdf 100644 --- a/locales/nl-NL/electron.json +++ b/locales/nl-NL/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Autorisatieverbinding mislukt", "waitingOAuth.helpText": "Als de browser niet automatisch is geopend, klik dan op annuleren en probeer het opnieuw", "waitingOAuth.retry": "Opnieuw proberen", - "waitingOAuth.title": "Wachten op autorisatieverbinding" + "waitingOAuth.title": "Wachten op autorisatieverbinding", + "window.close": "Venster sluiten", + "window.maximize": "Venster maximaliseren", + "window.minimize": "Venster minimaliseren" } diff --git a/locales/pl-PL/electron.json b/locales/pl-PL/electron.json index 6fc59f68fd..2883651432 100644 --- a/locales/pl-PL/electron.json +++ b/locales/pl-PL/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Nie udało się nawiązać połączenia autoryzacyjnego", "waitingOAuth.helpText": "Jeśli przeglądarka nie otworzyła się automatycznie, kliknij Anuluj i spróbuj ponownie", "waitingOAuth.retry": "Spróbuj ponownie", - "waitingOAuth.title": "Oczekiwanie na połączenie autoryzacyjne" + "waitingOAuth.title": "Oczekiwanie na połączenie autoryzacyjne", + "window.close": "Zamknij okno", + "window.maximize": "Maksymalizuj okno", + "window.minimize": "Minimalizuj okno" } diff --git a/locales/pt-BR/electron.json b/locales/pt-BR/electron.json index a5c7df8203..7684e1db97 100644 --- a/locales/pt-BR/electron.json +++ b/locales/pt-BR/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Falha na conexão de autorização", "waitingOAuth.helpText": "Se o navegador não abriu automaticamente, clique em cancelar e tente novamente", "waitingOAuth.retry": "Tentar novamente", - "waitingOAuth.title": "Aguardando conexão de autorização" + "waitingOAuth.title": "Aguardando conexão de autorização", + "window.close": "Fechar janela", + "window.maximize": "Maximizar janela", + "window.minimize": "Minimizar janela" } diff --git a/locales/ru-RU/electron.json b/locales/ru-RU/electron.json index 10a95502dd..88d3dfdf30 100644 --- a/locales/ru-RU/electron.json +++ b/locales/ru-RU/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Не удалось подключиться к авторизации", "waitingOAuth.helpText": "Если браузер не открылся автоматически, нажмите «Отмена» и попробуйте снова", "waitingOAuth.retry": "Повторить", - "waitingOAuth.title": "Ожидание подключения к авторизации" + "waitingOAuth.title": "Ожидание подключения к авторизации", + "window.close": "Закрыть окно", + "window.maximize": "Развернуть окно", + "window.minimize": "Свернуть окно" } diff --git a/locales/tr-TR/electron.json b/locales/tr-TR/electron.json index 62f2be7569..18029e8ae5 100644 --- a/locales/tr-TR/electron.json +++ b/locales/tr-TR/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Yetkilendirme Bağlantısı Başarısız", "waitingOAuth.helpText": "Tarayıcı otomatik olarak açılmadıysa, lütfen iptal edip tekrar deneyin", "waitingOAuth.retry": "Tekrar Dene", - "waitingOAuth.title": "Yetkilendirme Bağlantısı Bekleniyor" + "waitingOAuth.title": "Yetkilendirme Bağlantısı Bekleniyor", + "window.close": "Pencereyi kapat", + "window.maximize": "Pencereyi büyüt", + "window.minimize": "Pencereyi küçült" } diff --git a/locales/vi-VN/electron.json b/locales/vi-VN/electron.json index 0496038619..c207da0e2d 100644 --- a/locales/vi-VN/electron.json +++ b/locales/vi-VN/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "Kết nối xác thực thất bại", "waitingOAuth.helpText": "Nếu trình duyệt không tự động mở, vui lòng nhấn hủy và thử lại", "waitingOAuth.retry": "Thử lại", - "waitingOAuth.title": "Đang chờ kết nối xác thực" + "waitingOAuth.title": "Đang chờ kết nối xác thực", + "window.close": "Đóng cửa sổ", + "window.maximize": "Phóng to cửa sổ", + "window.minimize": "Thu nhỏ cửa sổ" } diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index 2efb70a387..7388dc8267 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "授权连接失败", "waitingOAuth.helpText": "如果浏览器没有自动打开,请点击取消后重新尝试", "waitingOAuth.retry": "重试", - "waitingOAuth.title": "等待授权连接" + "waitingOAuth.title": "等待授权连接", + "window.close": "关闭窗口", + "window.maximize": "最大化窗口", + "window.minimize": "最小化窗口" } diff --git a/locales/zh-TW/electron.json b/locales/zh-TW/electron.json index b858d274ea..7f52de592d 100644 --- a/locales/zh-TW/electron.json +++ b/locales/zh-TW/electron.json @@ -120,5 +120,8 @@ "waitingOAuth.errorTitle": "授權連接失敗", "waitingOAuth.helpText": "如果瀏覽器沒有自動打開,請點擊取消後重新嘗試", "waitingOAuth.retry": "重試", - "waitingOAuth.title": "等待授權連接" + "waitingOAuth.title": "等待授權連接", + "window.close": "關閉視窗", + "window.maximize": "最大化視窗", + "window.minimize": "最小化視窗" } diff --git a/src/features/Electron/titlebar/SimpleTitleBar.tsx b/src/features/Electron/titlebar/SimpleTitleBar.tsx index 940afb43f5..9435319b0b 100644 --- a/src/features/Electron/titlebar/SimpleTitleBar.tsx +++ b/src/features/Electron/titlebar/SimpleTitleBar.tsx @@ -6,8 +6,13 @@ import { type FC } from 'react'; import { ProductLogo } from '@/components/Branding/ProductLogo'; import { electronStylish } from '@/styles/electron'; +import { getPlatform, isMacOS } from '@/utils/platform'; import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate'; +import WinControl, { WINDOW_CONTROL_WIDTH } from './WinControl'; + +const isMac = isMacOS(); +const isLinux = getPlatform() === 'Linux'; /** * A simple, minimal TitleBar for Electron windows. @@ -16,16 +21,22 @@ import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate'; */ const SimpleTitleBar: FC = () => { useWatchThemeUpdate(); + + const showWinControl = isLinux && !isMac; + return ( + {showWinControl &&
} + {showWinControl && } ); }; diff --git a/src/features/Electron/titlebar/TitleBar.tsx b/src/features/Electron/titlebar/TitleBar.tsx index 2bb71be693..ff0a6a8c23 100644 --- a/src/features/Electron/titlebar/TitleBar.tsx +++ b/src/features/Electron/titlebar/TitleBar.tsx @@ -3,9 +3,8 @@ import { Flexbox } from '@lobehub/ui'; import { Divider } from 'antd'; import { memo, useMemo } from 'react'; -import { useElectronStore } from '@/store/electron'; import { electronStylish } from '@/styles/electron'; -import { isMacOS } from '@/utils/platform'; +import { getPlatform, isMacOS } from '@/utils/platform'; import Connection from '../connection/Connection'; import { useTabNavigation } from '../navigation/useTabNavigation'; @@ -16,18 +15,13 @@ import TabBar from './TabBar'; import WinControl from './WinControl'; const isMac = isMacOS(); +const isLinux = getPlatform() === 'Linux'; const TitleBar = memo(() => { - const [isAppStateInit, initElectronAppState] = useElectronStore((s) => [ - s.isAppStateInit, - s.useInitElectronAppState, - ]); - - initElectronAppState(); useWatchThemeUpdate(); useTabNavigation(); - const showWinControl = isAppStateInit && !isMac; + const showWinControl = isLinux && !isMac; const padding = useMemo(() => { if (showWinControl) { @@ -35,7 +29,7 @@ const TitleBar = memo(() => { } return '0 12px'; - }, [showWinControl, isMac]); + }, [showWinControl]); return ( { - return
; -}; +'use client'; + +import { ActionIcon, Flexbox } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { Maximize2Icon, Minimize2Icon, MinusIcon, XIcon } from 'lucide-react'; +import { memo, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { electronSystemService } from '@/services/electron/system'; +import { electronStylish } from '@/styles/electron'; + +export const WINDOW_CONTROL_WIDTH = 112; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + closeButton: css` + border-radius: 8px; + color: ${cssVar.colorTextSecondary}; + + &:hover { + color: ${cssVar.colorBgBase}; + background: ${cssVar.colorError}; + } + `, + container: css` + width: ${WINDOW_CONTROL_WIDTH}px; + min-width: ${WINDOW_CONTROL_WIDTH}px; + `, + controlButton: css` + border-radius: 8px; + color: ${cssVar.colorTextSecondary}; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + } + `, +})); + +const WinControl = memo(() => { + const { t } = useTranslation('electron'); + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + let mounted = true; + + const syncWindowState = async () => { + const nextState = await electronSystemService.isWindowMaximized(); + if (mounted) setIsMaximized(nextState); + }; + + void syncWindowState(); + + return () => { + mounted = false; + }; + }, []); + + const controls = useMemo( + () => [ + { + icon: MinusIcon, + key: 'minimize', + label: t('window.minimize'), + onClick: () => void electronSystemService.minimizeWindow(), + }, + { + icon: isMaximized ? Minimize2Icon : Maximize2Icon, + key: 'maximize', + label: t(isMaximized ? 'window.restore' : 'window.maximize'), + onClick: async () => { + await electronSystemService.maximizeWindow(); + setIsMaximized(await electronSystemService.isWindowMaximized()); + }, + }, + { + icon: XIcon, + key: 'close', + label: t('window.close'), + onClick: () => void electronSystemService.closeWindow(), + }, + ], + [isMaximized, t], + ); + + return ( + + {controls.map((control) => ( + + ))} + + ); +}); export default WinControl; diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index 5bb3bffbdc..8856a4d5d4 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -127,4 +127,8 @@ export default { 'If the browser did not open automatically, please click cancel and try again', 'waitingOAuth.retry': 'Retry', 'waitingOAuth.title': 'Waiting for Authorization Connection', + 'window.close': 'Close window', + 'window.maximize': 'Maximize window', + 'window.minimize': 'Minimize window', + 'window.restore': 'Restore window', }; diff --git a/src/services/electron/system.ts b/src/services/electron/system.ts index ce61eca8a7..8dcd6490d3 100644 --- a/src/services/electron/system.ts +++ b/src/services/electron/system.ts @@ -32,6 +32,10 @@ class ElectronSystemService { return this.ipc.windows.maximizeWindow(); } + async isWindowMaximized(): Promise { + return this.ipc.windows.isWindowMaximized(); + } + async minimizeWindow(): Promise { return this.ipc.windows.minimizeWindow(); }