diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..7bda2060afe 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -251,6 +251,7 @@ describe("DesktopServerExposure", () => { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setLinuxNativeWindowFrame: () => Effect.die("unexpected Linux window frame change"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..fb02dc6bfe7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,10 +35,12 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getLinuxNativeWindowFrame, getWindowFullscreenState, openExternal, pickFolder, setTheme, + setLinuxNativeWindowFrame, showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; @@ -52,6 +54,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(getLinuxNativeWindowFrame); + yield* ipc.handle(setLinuxNativeWindowFrame); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..a61f97abd20 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -6,6 +6,8 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const GET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL = "desktop:get-linux-native-window-frame"; +export const SET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL = "desktop:set-linux-native-window-frame"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..df7ee0a530d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -7,8 +7,15 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { + getLinuxNativeWindowFrame, + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + setLinuxNativeWindowFrame, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +153,39 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("Linux native window frame settings", () => { + it.effect("reads and changes the preference on Linux", () => { + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest(), + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "linux", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ); + + return Effect.gen(function* () { + assert.isFalse(yield* getLinuxNativeWindowFrame.handler(undefined)); + assert.isTrue(yield* setLinuxNativeWindowFrame.handler(true)); + assert.isTrue(yield* getLinuxNativeWindowFrame.handler(undefined)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports the preference as unsupported outside Linux", () => { + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + linuxNativeWindowFrame: true, + }), + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ); + + return Effect.gen(function* () { + assert.isNull(yield* getLinuxNativeWindowFrame.handler(undefined)); + assert.isFalse(yield* setLinuxNativeWindowFrame.handler(false)); + const settings = yield* DesktopAppSettings.DesktopAppSettings; + assert.isTrue((yield* settings.get).linuxNativeWindowFrame); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..fad20cb1041 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -65,6 +65,34 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getLinuxNativeWindowFrame = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.Boolean), + handler: Effect.fn("desktop.ipc.window.getLinuxNativeWindowFrame")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "linux") { + return null; + } + const settings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* settings.get).linuxNativeWindowFrame; + }), +}); + +export const setLinuxNativeWindowFrame = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL, + payload: Schema.Boolean, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.setLinuxNativeWindowFrame")(function* (enabled) { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "linux") { + return false; + } + const settings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* settings.setLinuxNativeWindowFrame(enabled)).settings.linuxNativeWindowFrame; + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..e9a64bff284 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -129,6 +129,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); }; }, + getLinuxNativeWindowFrame: () => + ipcRenderer.invoke(IpcChannels.GET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL), + setLinuxNativeWindowFrame: (enabled: boolean) => + ipcRenderer.invoke(IpcChannels.SET_LINUX_NATIVE_WINDOW_FRAME_CHANNEL, enabled), getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe..84ad8e2a01f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + linuxNativeWindowFrame: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey( Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), ), @@ -105,6 +106,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -125,6 +127,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxNativeWindowFrame: true, linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -134,6 +137,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: true, linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, @@ -192,6 +196,9 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; + const nativeFrame = yield* settings.setLinuxNativeWindowFrame(false); + assert.isFalse(nativeFrame.changed); + const exposure = yield* settings.setServerExposureMode("local-only"); assert.isFalse(exposure.changed); @@ -241,6 +248,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, @@ -297,6 +305,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -320,6 +329,7 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setLinuxNativeWindowFrame(true); yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); @@ -327,6 +337,7 @@ describe("DesktopSettings", () => { yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + linuxNativeWindowFrame: true, mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", @@ -345,6 +356,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -373,6 +385,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -400,6 +413,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxNativeWindowFrame: false, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc6752553..9e8ecd092c8 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly linuxNativeWindowFrame: boolean; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + linuxNativeWindowFrame: false, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + linuxNativeWindowFrame: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -156,6 +159,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setLinuxNativeWindowFrame: ( + enabled: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + linuxNativeWindowFrame: parsed.linuxNativeWindowFrame === true, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.linuxNativeWindowFrame !== defaults.linuxNativeWindowFrame) { + document.linuxNativeWindowFrame = settings.linuxNativeWindowFrame; + } if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -312,6 +322,15 @@ function setMainWindowBounds( }; } +function setLinuxNativeWindowFrame(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.linuxNativeWindowFrame === enabled + ? settings + : { + ...settings, + linuxNativeWindowFrame: enabled, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -518,6 +537,12 @@ export const make = Effect.gen(function* () { }, }), ), + setLinuxNativeWindowFrame: (enabled) => + persist((settings) => setLinuxNativeWindowFrame(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLinuxNativeWindowFrame", { + attributes: { enabled }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -577,6 +602,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setLinuxNativeWindowFrame: (enabled) => + update((settings) => setLinuxNativeWindowFrame(settings, enabled)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..b5257f240f4 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -159,6 +159,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setLinuxNativeWindowFrame: () => Effect.die("unexpected Linux window frame change"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0..e2e0e68eeaf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -211,6 +211,7 @@ function makeTestLayer(input: { } return { settings: desktopSettings, changed }; }), + setLinuxNativeWindowFrame: () => Effect.die("unexpected Linux window frame change"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -367,6 +368,28 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("uses native window decorations only when the Linux preference is enabled", () => { + assert.deepEqual(DesktopWindow.resolveWindowTitleBarOptions(false, "linux", true), { + frame: true, + }); + assert.deepEqual(DesktopWindow.resolveWindowTitleBarOptions(false, "win32", true), { + titleBarStyle: "hidden", + titleBarOverlay: { + color: "#01000000", + height: 40, + symbolColor: "#1f2937", + }, + }); + assert.deepEqual(DesktopWindow.resolveWindowTitleBarOptions(false, "linux", false), { + titleBarStyle: "hidden", + titleBarOverlay: { + color: "#01000000", + height: 40, + symbolColor: "#1f2937", + }, + }); + }); + it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..cabaa510359 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -44,7 +44,7 @@ const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ type WindowTitleBarOptions = Pick< Electron.BrowserWindowConstructorOptions, - "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" + "frame" | "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" >; type DesktopWindowRuntimeServices = @@ -188,10 +188,15 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { ); } -function getWindowTitleBarOptions( +export function resolveWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, + linuxNativeWindowFrame: boolean, ): WindowTitleBarOptions { + if (platform === "linux" && linuxNativeWindowFrame) { + return { frame: true }; + } + if (platform === "darwin") { return { titleBarStyle: "hiddenInset", @@ -201,18 +206,22 @@ function getWindowTitleBarOptions( return { titleBarStyle: "hidden", - titleBarOverlay: { - color: TITLEBAR_COLOR, - height: TITLEBAR_HEIGHT, - symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, - }, + titleBarOverlay: getTitleBarOverlay(shouldUseDarkColors), }; } +function getTitleBarOverlay(shouldUseDarkColors: boolean) { + return { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, + } as const; +} + function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, - platform: NodeJS.Platform, + usesTitleBarOverlay: boolean, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -220,10 +229,10 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); - if (typeof titleBarOverlay === "object") { - window.setTitleBarOverlay(titleBarOverlay); + if (!usesTitleBarOverlay) { + return; } + window.setTitleBarOverlay(getTitleBarOverlay(shouldUseDarkColors)); }); } @@ -265,6 +274,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const windowsWithTitleBarOverlay = new WeakSet(); let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { @@ -321,6 +331,11 @@ export const make = Effect.gen(function* () { cause: displayBoundsResult.cause, }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const titleBarOptions = resolveWindowTitleBarOptions( + shouldUseDarkColors, + environment.platform, + persistedSettings.linuxNativeWindowFrame, + ); const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); @@ -335,7 +350,7 @@ export const make = Effect.gen(function* () { backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), + ...titleBarOptions, webPreferences: { preload: environment.preloadPath, backgroundThrottling: false, @@ -345,6 +360,9 @@ export const make = Effect.gen(function* () { webviewTag: true, }, }); + if (typeof titleBarOptions.titleBarOverlay === "object") { + windowsWithTitleBarOverlay.add(window); + } if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -839,7 +857,7 @@ export const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + syncWindowAppearance(window, shouldUseDarkColors, windowsWithTitleBarOverlay.has(window)), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e3e0a22a0c3..d01a9b94add 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -992,6 +992,85 @@ function BackgroundActivityAdvancedDialog({ ); } +function LinuxNativeWindowFrameSetting() { + const getLinuxNativeWindowFrame = window.desktopBridge?.getLinuxNativeWindowFrame; + const setLinuxNativeWindowFrame = window.desktopBridge?.setLinuxNativeWindowFrame; + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + const [supported, setSupported] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if ( + typeof getLinuxNativeWindowFrame !== "function" || + typeof setLinuxNativeWindowFrame !== "function" + ) { + return; + } + + let cancelled = false; + void getLinuxNativeWindowFrame() + .then((current) => { + if (!cancelled) { + setSupported(current !== null); + if (current !== null) { + setEnabled(current); + } + setLoaded(true); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + console.error("Could not read the Linux native window frame setting.", error); + } + }); + + return () => { + cancelled = true; + }; + }, [getLinuxNativeWindowFrame, setLinuxNativeWindowFrame]); + + if ( + typeof getLinuxNativeWindowFrame !== "function" || + typeof setLinuxNativeWindowFrame !== "function" || + !loaded || + !supported + ) { + return null; + } + + const updateNativeWindowFrame = (next: boolean) => { + setSaving(true); + void setLinuxNativeWindowFrame(next) + .then(setEnabled) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change window frame", + description: error instanceof Error ? error.message : "Desktop settings write failed.", + }), + ); + }) + .finally(() => setSaving(false)); + }; + + return ( + updateNativeWindowFrame(Boolean(checked))} + aria-label="Use native Linux window frame" + /> + } + /> + ); +} + export function AppearanceSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); @@ -1042,6 +1121,8 @@ export function AppearanceSettingsPanel() { } /> + + void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; + /** Linux-only. Returns null on Windows and macOS. */ + getLinuxNativeWindowFrame?: () => Promise; + /** Linux-only. The new frame style takes effect after the desktop app restarts. */ + setLinuxNativeWindowFrame?: (enabled: boolean) => Promise; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise;