fix(electron): restore custom title bar on GNOME-X11 (#8483) (#8485)

#7097 force-disabled the custom title bar on all GNOME and hid the
toggle, even though only GNOME+Wayland actually fails to render the
Window-Controls-Overlay. This stranded GNOME-X11 users (where the
feature worked) with no way to re-enable it.

- Narrow the kill-switch from IS_GNOME_DESKTOP to a new IS_GNOME_WAYLAND
  (XDG_SESSION_TYPE/WAYLAND_DISPLAY, mirroring idle-time-handler). GNOME
  on X11 keeps the feature and the settings toggle.
- Unify GNOME detection: preload imports it from common.const instead of
  re-implementing, so main and renderer can't drift.
- Align global-theme.service with main-window: force native decorations
  on GNOME+Wayland in both, so a persisted/synced `true` no longer lays
  the custom header over native controls (doubled header).
- Add unit tests for the detection matrix.
This commit is contained in:
Johannes Millan 2026-06-19 12:42:44 +02:00 committed by GitHub
parent cbe3f1527f
commit b2a68eddee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 134 additions and 43 deletions

View file

@ -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,
);
});

View file

@ -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);

View file

@ -136,6 +136,8 @@ export interface ElectronAPI {
isGnomeDesktop(): boolean;
isGnomeWayland(): boolean;
isMacOS(): boolean;
isAppleSilicon(): boolean;

View file

@ -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

View file

@ -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<unknown>
...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,

View file

@ -14,7 +14,11 @@ export const IS_ELECTRON_TOKEN = new InjectionToken<boolean>('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();

View file

@ -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<DarkModeCfg>(

View file

@ -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,

View file

@ -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<MiscConfig> = {
@ -102,21 +102,21 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection<MiscConfig> = {
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: {