diff --git a/electron/common-const.test.cjs b/electron/common-const.test.cjs new file mode 100644 index 0000000000..39e594f7da --- /dev/null +++ b/electron/common-const.test.cjs @@ -0,0 +1,62 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +require('ts-node/register/transpile-only'); + +const { + isGnomeDesktopEnv, + isWaylandEnv, + isGnomeWaylandEnv, +} = require(path.resolve(__dirname, 'common.const.ts')); + +test('isGnomeDesktopEnv: detects GNOME from desktop env vars', () => { + assert.equal(isGnomeDesktopEnv('linux', { XDG_CURRENT_DESKTOP: 'GNOME' }), true); + assert.equal(isGnomeDesktopEnv('linux', { XDG_CURRENT_DESKTOP: 'ubuntu:GNOME' }), true); + assert.equal(isGnomeDesktopEnv('linux', { DESKTOP_SESSION: 'ubuntu' }), true); + assert.equal(isGnomeDesktopEnv('linux', { XDG_SESSION_DESKTOP: 'gnome' }), true); +}); + +test('isGnomeDesktopEnv: false for non-GNOME / non-linux', () => { + assert.equal(isGnomeDesktopEnv('linux', { XDG_CURRENT_DESKTOP: 'KDE' }), false); + assert.equal(isGnomeDesktopEnv('linux', {}), false); + // env vars are present but platform is not linux + assert.equal(isGnomeDesktopEnv('win32', { XDG_CURRENT_DESKTOP: 'GNOME' }), false); + assert.equal(isGnomeDesktopEnv('darwin', { XDG_CURRENT_DESKTOP: 'GNOME' }), false); +}); + +test('isWaylandEnv: detects Wayland via session type or display', () => { + assert.equal(isWaylandEnv('linux', { XDG_SESSION_TYPE: 'wayland' }), true); + // some sessions only set WAYLAND_DISPLAY + assert.equal(isWaylandEnv('linux', { WAYLAND_DISPLAY: 'wayland-0' }), true); +}); + +test('isWaylandEnv: false for X11 / non-linux', () => { + assert.equal(isWaylandEnv('linux', { XDG_SESSION_TYPE: 'x11' }), false); + assert.equal(isWaylandEnv('linux', {}), false); + assert.equal(isWaylandEnv('win32', { WAYLAND_DISPLAY: 'wayland-0' }), false); +}); + +test('isGnomeWaylandEnv: only true for GNOME AND Wayland together', () => { + // GNOME + Wayland -> the one combination the kill-switch targets + assert.equal( + isGnomeWaylandEnv('linux', { + XDG_CURRENT_DESKTOP: 'GNOME', + XDG_SESSION_TYPE: 'wayland', + }), + true, + ); + // GNOME on X11 keeps the feature + assert.equal( + isGnomeWaylandEnv('linux', { + XDG_CURRENT_DESKTOP: 'GNOME', + XDG_SESSION_TYPE: 'x11', + }), + false, + ); + // non-GNOME Wayland (e.g. KDE) is unaffected + assert.equal( + isGnomeWaylandEnv('linux', { XDG_CURRENT_DESKTOP: 'KDE', XDG_SESSION_TYPE: 'wayland' }), + false, + ); +}); diff --git a/electron/common.const.ts b/electron/common.const.ts index 9ed099ab72..4098fc3ca1 100644 --- a/electron/common.const.ts +++ b/electron/common.const.ts @@ -1,12 +1,35 @@ -export const IS_MAC = process.platform === 'darwin'; -export const IS_GNOME_DESKTOP = - process.platform === 'linux' && +export const isGnomeDesktopEnv = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean => + platform === 'linux' && [ - process.env.XDG_CURRENT_DESKTOP, - process.env.XDG_SESSION_DESKTOP, - process.env.DESKTOP_SESSION, - process.env.GNOME_SHELL_SESSION_MODE, + env.XDG_CURRENT_DESKTOP, + env.XDG_SESSION_DESKTOP, + env.DESKTOP_SESSION, + env.GNOME_SHELL_SESSION_MODE, ] .filter((v): v is string => !!v) .map((v) => v.toLowerCase()) .some((v) => v.includes('gnome') || v.includes('ubuntu')); + +// Some sessions only set WAYLAND_DISPLAY and leave XDG_SESSION_TYPE unset, so +// check both. +export const isWaylandEnv = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean => + platform === 'linux' && (env.XDG_SESSION_TYPE === 'wayland' || !!env.WAYLAND_DISPLAY); + +// Only GNOME on Wayland reliably fails to render the Window-Controls-Overlay +// when titleBarStyle is 'hidden', stranding the window with no min/max/close +// controls. The custom-title-bar kill-switch is narrowed to this combination +// so GNOME-on-X11 (where the feature works) keeps it. +export const isGnomeWaylandEnv = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean => isGnomeDesktopEnv(platform, env) && isWaylandEnv(platform, env); + +export const IS_MAC = process.platform === 'darwin'; +export const IS_GNOME_DESKTOP = isGnomeDesktopEnv(process.platform, process.env); +export const IS_GNOME_WAYLAND = isGnomeWaylandEnv(process.platform, process.env); diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 92d83067dd..5053798c19 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -136,6 +136,8 @@ export interface ElectronAPI { isGnomeDesktop(): boolean; + isGnomeWayland(): boolean; + isMacOS(): boolean; isAppleSilicon(): boolean; diff --git a/electron/main-window.ts b/electron/main-window.ts index 5bfcb5cedd..c32ec6f057 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -16,7 +16,7 @@ import { IPC } from './shared-with-frontend/ipc-events.const'; import { isExternalUrlSchemeAllowed } from './shared-with-frontend/is-external-url-allowed'; import { readFileSync, stat, writeFileSync } from 'fs'; import { error, log } from 'electron-log/main'; -import { IS_MAC, IS_GNOME_DESKTOP } from './common.const'; +import { IS_MAC, IS_GNOME_WAYLAND } from './common.const'; import { destroyTaskWidget, getIsTaskWidgetAlwaysShow, @@ -148,10 +148,12 @@ export const createWindow = async ({ const userPrefersCustomWindowTitleBar = persistedIsUseCustomWindowTitleBar ?? legacyIsUseObsidianStyleHeader ?? - !IS_GNOME_DESKTOP; - // GNOME + Wayland combinations can miss native controls when titleBarStyle is hidden. - // Force native decorations on GNOME to keep window controls available. - const isUseCustomWindowTitleBar = IS_GNOME_DESKTOP + !IS_GNOME_WAYLAND; + // GNOME + Wayland can't render the Window-Controls-Overlay when titleBarStyle + // is 'hidden', leaving the window with no min/max/close controls. Force native + // decorations only for that combination; GNOME-on-X11 and every other desktop + // honor the user's preference. Keep in sync with global-theme.service.ts. + const isUseCustomWindowTitleBar = IS_GNOME_WAYLAND ? false : userPrefersCustomWindowTitleBar; // On macOS use 'hiddenInset' so AppKit positions the traffic lights at the diff --git a/electron/preload.ts b/electron/preload.ts index f5b3ca4245..b034803142 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -6,6 +6,7 @@ import { webUtils, } from 'electron'; import { ElectronAPI } from './electronAPI.d'; +import { IS_GNOME_DESKTOP, IS_GNOME_WAYLAND } from './common.const'; import { IPC, IPCEventValue } from './shared-with-frontend/ipc-events.const'; import { getDistChannel, @@ -30,25 +31,6 @@ const _invoke: (channel: IPCEventValue, ...args: unknown[]) => Promise ...args ) => ipcRenderer.invoke(channel, ...args); -const isGnomeDesktop = (): boolean => { - if (process.platform !== 'linux') { - return false; - } - - const desktopValues = [ - process.env.XDG_CURRENT_DESKTOP, - process.env.XDG_SESSION_DESKTOP, - process.env.DESKTOP_SESSION, - process.env.GNOME_SHELL_SESSION_MODE, - ] - .filter((value): value is string => !!value) - .map((value) => value.toLowerCase()); - - return desktopValues.some( - (value) => value.includes('gnome') || value.includes('ubuntu'), - ); -}; - const ea: ElectronAPI = { on: ( channel: string, @@ -108,7 +90,8 @@ const ea: ElectronAPI = { }, getZoomFactor: () => webFrame.getZoomFactor(), isLinux: () => process.platform === 'linux', - isGnomeDesktop, + isGnomeDesktop: () => IS_GNOME_DESKTOP, + isGnomeWayland: () => IS_GNOME_WAYLAND, isMacOS: () => process.platform === 'darwin', isAppleSilicon: () => process.platform === 'darwin' && process.arch === 'arm64', isSnap: () => process && process.env && !!process.env.SNAP, diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 71c32e6361..5edb011be5 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -14,7 +14,11 @@ export const IS_ELECTRON_TOKEN = new InjectionToken('IS_ELECTRON', { }); // effectively IS_BROWSER export const IS_WEB_BROWSER = !IS_ELECTRON && !IS_ANDROID_WEB_VIEW; -export const IS_GNOME_DESKTOP = IS_ELECTRON && window.ea.isGnomeDesktop(); +// Only GNOME+Wayland force-disables the custom title bar (the Window-Controls- +// Overlay won't render there). Mirrors electron/common.const.ts so the renderer +// and main process agree. See global-theme.service.ts / main-window.ts. +// (window.ea.isGnomeDesktop() still exists on the bridge for plugin use.) +export const IS_GNOME_WAYLAND = IS_ELECTRON && window.ea.isGnomeWayland(); // True only inside the Electron build — preload exposes process.arch. // Web builds can't reliably distinguish Apple Silicon from Intel and stay false. export const IS_APPLE_SILICON = IS_ELECTRON && window.ea.isAppleSilicon(); diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index 4e18df762d..faca61ff11 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -9,7 +9,7 @@ import { untracked, } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { BodyClass, IS_ELECTRON, IS_GNOME_DESKTOP } from '../../app.constants'; +import { BodyClass, IS_ELECTRON, IS_GNOME_WAYLAND } from '../../app.constants'; import { IS_MAC } from '../../util/is-mac'; import { distinctUntilChanged, map, startWith, switchMap, take } from 'rxjs/operators'; import { IS_TOUCH_ONLY } from '../../util/is-touch-only'; @@ -93,8 +93,22 @@ export class GlobalThemeService { private _iosViewportChangeRaf: number | null = null; private _isCustomWindowTitleBarEnabled(): boolean { + // The main process (main-window.ts) force-disables the custom title bar on + // GNOME+Wayland because the Window-Controls-Overlay won't render there. + // Mirror that here so we never lay the custom header on top of native + // decorations, which would produce a doubled header. + if (IS_GNOME_WAYLAND) { + return false; + } + // Default ON to match main-window's `?? !IS_GNOME_WAYLAND` default. + // KNOWN RESIDUAL: main-window also honors the legacy `isUseObsidianStyleHeader` + // SimpleStore field, which is never mirrored into global config, so it isn't + // visible here. A user who explicitly disabled the *old* header and never set + // the new toggle gets native decorations + custom header (doubled). This is a + // pre-existing divergence (already present for non-GNOME) and is self-healing: + // the now-visible Misc toggle lets them turn the custom header off. const misc = this._globalConfigService.misc(); - return misc?.isUseCustomWindowTitleBar ?? !IS_GNOME_DESKTOP; + return misc?.isUseCustomWindowTitleBar ?? true; } darkMode = signal( diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 7faf15a3e2..331ddd550a 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -63,8 +63,9 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { // NOTE: isUseCustomWindowTitleBar is intentionally NOT defaulted here. A // persisted default would be pushed to Electron on every launch and override // a legacy `isUseObsidianStyleHeader` choice. Its effective default is resolved - // at read time (main-window.ts / global-theme.service.ts: `?? !IS_GNOME_DESKTOP`) - // and the settings checkbox is seeded display-only in misc-settings-form (#7891). + // at read time (main-window.ts / global-theme.service.ts: defaults on, forced + // off only on GNOME+Wayland) and the settings checkbox is seeded display-only + // in misc-settings-form (#7891). isShowProductivityTipLonger: false, customTheme: 'default', defaultStartPage: 0, diff --git a/src/app/features/config/form-cfgs/misc-settings-form.const.ts b/src/app/features/config/form-cfgs/misc-settings-form.const.ts index acbfe38ca5..b94d9ea060 100644 --- a/src/app/features/config/form-cfgs/misc-settings-form.const.ts +++ b/src/app/features/config/form-cfgs/misc-settings-form.const.ts @@ -4,7 +4,7 @@ import { MiscConfig, } from '../global-config.model'; import { T } from '../../../t.const'; -import { IS_ELECTRON, IS_GNOME_DESKTOP } from '../../../app.constants'; +import { IS_ELECTRON, IS_GNOME_WAYLAND } from '../../../app.constants'; import { isValidSplitTime } from '../../../util/is-valid-split-time'; export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { @@ -102,21 +102,21 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { label: T.GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN, }, }, - ...((IS_ELECTRON && !IS_GNOME_DESKTOP + ...((IS_ELECTRON && !IS_GNOME_WAYLAND ? [ { key: 'isUseCustomWindowTitleBar', type: 'checkbox', // Display-only default: seed the checkbox so it reflects the actual // window state on a fresh install (the custom title bar is on by - // default here -- this field is only shown on non-GNOME Electron, see - // the enclosing guard). formly seeds the control without an initial - // modelChange, so nothing is persisted on load. + // default here -- this field is hidden only on GNOME+Wayland, where the + // main process force-disables it, see the enclosing guard). formly seeds + // the control without an initial modelChange, so nothing is persisted on load. // KNOWN RESIDUAL (#7891): saving any *other* Misc setting emits the // whole model and persists this seeded value too. We accept that over a // persisted DEFAULT_GLOBAL_CONFIG default, which would be pushed to // Electron on *every* launch and override a legacy `isUseObsidianStyleHeader` - // choice. So a pre-2025-12 non-GNOME user who had disabled the old + // choice. So a pre-2025-12 user who had disabled the old // header may see it re-enabled after editing Misc settings (reversible here). defaultValue: true, templateOptions: {