From 9fcbb00ea1535621b9098e5da32b524595a1e01b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 24 Aug 2026 12:58:05 +0200 Subject: [PATCH 1/2] feat(linux): add opt-in X11 backend for tray-anchored positioning --- src/main/handlers/system.ts | 9 ++ src/main/index.ts | 5 + src/main/ozone.test.ts | 97 +++++++++++++++++++ src/main/ozone.ts | 68 +++++++++++++ src/preload/index.ts | 8 ++ src/renderer/__helpers__/visual.setup.ts | 1 + src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/__mocks__/state-mocks.ts | 1 + .../settings/SystemSettings.test.tsx | 28 ++++++ .../components/settings/SystemSettings.tsx | 17 ++++ src/renderer/stores/defaults.ts | 1 + src/renderer/stores/subscriptions.ts | 12 +++ src/renderer/types.ts | 2 + src/renderer/utils/system/comms.ts | 12 +++ src/shared/events.ts | 5 + 15 files changed, 267 insertions(+) create mode 100644 src/main/ozone.test.ts create mode 100644 src/main/ozone.ts diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index c23c97f27..d2c49411a 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -6,6 +6,7 @@ import { logInfo } from '../../shared/logger'; import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; import { applyKeepWindowOnBlur, applyWindowVibrancy } from '../lifecycle/window'; +import { setX11Backend } from '../ozone'; import { isDevMode } from '../utils'; /** @@ -92,6 +93,14 @@ export function registerSystemHandlers(mb: Menubar): void { applyKeepWindowOnBlur(mb, value); }); + /** + * Persist the Linux X11 backend preference. Only read during startup, so the + * change applies on the next launch. + */ + onMainEvent(EVENTS.UPDATE_USE_X11_BACKEND, (_, value: boolean) => { + setX11Backend(value); + }); + /** * Toggle the macOS window vibrancy material for the Glass design language. * Request/response so the renderer can await the material before clearing the diff --git a/src/main/index.ts b/src/main/index.ts index 11eb6c3be..c17891d54 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,9 +17,14 @@ import { onFirstRunMaybe, } from './lifecycle'; import MenuBuilder from './menu'; +import { applyOzonePlatform } from './ozone'; import AppUpdater from './updater'; import { isDevMode } from './utils'; +// Runs at module load: the Ozone platform is read during app startup, so this +// has to happen before `app.whenReady()` below. +applyOzonePlatform(); + log.initialize(); if (!app.isPackaged) { diff --git a/src/main/ozone.test.ts b/src/main/ozone.test.ts new file mode 100644 index 000000000..129834ea2 --- /dev/null +++ b/src/main/ozone.test.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { applyOzonePlatform, isX11BackendEnabled, setX11Backend } from './ozone'; + +const USER_DATA = '/tmp/gitify-test-userdata'; +const MARKER = path.join(USER_DATA, 'UseX11Backend'); + +const appendSwitchMock = vi.fn(); + +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => (name === 'userData' ? '/tmp/gitify-test-userdata' : ''), + commandLine: { + appendSwitch: (...a: unknown[]) => appendSwitchMock(...a), + }, + }, +})); + +const logErrorMock = vi.fn(); +vi.mock('../shared/logger', () => ({ + logError: (...a: unknown[]) => logErrorMock(...a), + logInfo: vi.fn(), + toError: (e: unknown) => e, +})); + +/** Swap `process.platform`, which is read-only on the real process object. */ +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +describe('main/ozone.ts', () => { + const realPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + // Electron creates userData for a real app; the stubbed path needs it too. + fs.mkdirSync(USER_DATA, { recursive: true }); + fs.rmSync(MARKER, { force: true }); + }); + + afterEach(() => { + setPlatform(realPlatform); + fs.rmSync(MARKER, { force: true }); + }); + + describe('setX11Backend / isX11BackendEnabled', () => { + it('reports disabled when no marker exists', () => { + expect(isX11BackendEnabled()).toBe(false); + }); + + it('round-trips the preference through the marker file', () => { + setX11Backend(true); + expect(fs.existsSync(MARKER)).toBe(true); + expect(isX11BackendEnabled()).toBe(true); + + setX11Backend(false); + expect(fs.existsSync(MARKER)).toBe(false); + expect(isX11BackendEnabled()).toBe(false); + }); + + it('is idempotent when disabling with no marker present', () => { + expect(() => setX11Backend(false)).not.toThrow(); + expect(logErrorMock).not.toHaveBeenCalled(); + }); + }); + + describe('applyOzonePlatform', () => { + it('forces x11 on Linux when enabled', () => { + setPlatform('linux'); + setX11Backend(true); + + applyOzonePlatform(); + + expect(appendSwitchMock).toHaveBeenCalledWith('ozone-platform', 'x11'); + }); + + it('leaves the platform alone on Linux when disabled', () => { + setPlatform('linux'); + + applyOzonePlatform(); + + expect(appendSwitchMock).not.toHaveBeenCalled(); + }); + + it('never forces x11 off Linux, even with a stale marker', () => { + // A marker copied between machines, or left by a previous Linux install + // sharing a synced profile, must not affect macOS or Windows. + setPlatform('darwin'); + setX11Backend(true); + + applyOzonePlatform(); + + expect(appendSwitchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/ozone.ts b/src/main/ozone.ts new file mode 100644 index 000000000..f3d01ee89 --- /dev/null +++ b/src/main/ozone.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { app } from 'electron'; + +import { logError, logInfo, toError } from '../shared/logger'; + +/** + * Marker file in the user data directory whose presence selects the X11 + * backend. The renderer owns every other setting via `localStorage`, which the + * main process cannot read, and the Ozone platform has to be chosen before the + * app is ready — long before a renderer exists. A file on disk is the only + * state available that early, so this setting is mirrored here rather than + * read from the settings store. + */ +const X11_MARKER_FILE = 'UseX11Backend'; + +const markerPath = (): string => path.join(app.getPath('userData'), X11_MARKER_FILE); + +/** + * Whether the user has opted into the X11 backend. + */ +export function isX11BackendEnabled(): boolean { + try { + return fs.existsSync(markerPath()); + } catch (err) { + logError('isX11BackendEnabled', 'Unable to read X11 backend marker', toError(err)); + return false; + } +} + +/** + * Persist the X11 backend preference. Takes effect on the next launch, since + * the Ozone platform is fixed once the app has started. + * + * @param enabled - `true` to run under X11/XWayland, `false` to let Electron pick. + */ +export function setX11Backend(enabled: boolean): void { + try { + if (enabled) { + fs.writeFileSync(markerPath(), ''); + return; + } + + fs.rmSync(markerPath(), { force: true }); + } catch (err) { + logError('setX11Backend', 'Unable to persist X11 backend preference', toError(err)); + } +} + +/** + * Force the X11 Ozone backend when the user has opted in. + * + * Must run before the app is ready. Electron 38+ defaults the platform hint to + * `auto`, so a Wayland session gets a native Wayland client, where the + * compositor owns window placement and the tray reports no coordinates. The + * popup then opens centre-screen instead of under the tray icon. Running under + * XWayland restores tray-anchored positioning at the cost of native Wayland + * scaling, so it stays opt-in. + */ +export function applyOzonePlatform(): void { + if (process.platform !== 'linux' || !isX11BackendEnabled()) { + return; + } + + app.commandLine.appendSwitch('ozone-platform', 'x11'); + logInfo('applyOzonePlatform', 'X11 backend enabled, forcing --ozone-platform=x11'); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 123527307..6364cbd50 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,14 @@ export const api = { */ setKeepWindowOnBlur: (value: boolean) => sendMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, value), + /** + * Persist whether Linux should run under the X11 backend. Applied at startup, + * so the change only takes effect after the app is restarted. + * + * @param value - `true` to force X11/XWayland, `false` to let Electron pick. + */ + setUseX11Backend: (value: boolean) => sendMainEvent(EVENTS.UPDATE_USE_X11_BACKEND, value), + /** * Enable or disable the macOS window vibrancy material for Glass. Resolves once * the material has been applied so the renderer can order the visual switch. diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index cff711fa5..693759d46 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -109,6 +109,7 @@ function createGitifyBridgeApi(): Window['gitify'] { onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), + setUseX11Backend: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), raiseNativeNotification: vi.fn(), }; diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 0b4e1bb2c..48535f3bc 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -107,6 +107,7 @@ function createGitifyBridgeApi(): Window['gitify'] { onSystemWake: vi.fn(() => vi.fn()), setAutoLaunch: vi.fn(), setKeepWindowOnBlur: vi.fn(), + setUseX11Backend: vi.fn(), applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }), raiseNativeNotification: vi.fn(), }; diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 4e0a7eea3..1f3de0afe 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -63,6 +63,7 @@ const mockSystemSettings: SystemSettingsState = { notificationVolume: 20 as Percentage, openAtStartup: false, keepWindowOnBlur: false, + useX11Backend: false, }; export const mockSettings: SettingsState = { diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index 26faabd26..7d9b32e50 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -46,6 +46,34 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); + describe('X11 backend checkbox', () => { + // `window.gitify` is rebuilt in a global `beforeEach`, so the mock has to + // be read inside each test rather than captured at describe scope. + const isLinuxMock = () => window.gitify.platform.isLinux as unknown as ReturnType; + + it('is hidden off Linux', async () => { + isLinuxMock().mockReturnValue(false); + + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByTestId('checkbox-useX11Backend')).not.toBeInTheDocument(); + }); + + it('is shown and toggles on Linux', async () => { + isLinuxMock().mockReturnValue(true); + + await act(async () => { + renderWithProviders(); + }); + + await userEvent.click(screen.getByTestId('checkbox-useX11Backend')); + + expect(toggleSettingSpy).toHaveBeenCalledWith('useX11Backend'); + }); + }); + it('should reset global shortcut to default when customized', async () => { renderWithProviders(, { settings: { diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 3f06b3ee0..11d4ce5e1 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -51,6 +51,7 @@ export const SystemSettings: FC = () => { const notificationVolume = useSettingsStore((s) => s.notificationVolume); const keepWindowOnBlur = useSettingsStore((s) => s.keepWindowOnBlur); const openAtStartup = useSettingsStore((s) => s.openAtStartup); + const useX11Backend = useSettingsStore((s) => s.useX11Backend); const [recordingShortcut, setRecordingShortcut] = useState(false); const [liveModifierAccelerator, setLiveModifierAccelerator] = useState(''); @@ -343,6 +344,22 @@ export const SystemSettings: FC = () => { tooltip={Launch {APPLICATION.NAME} automatically at startup.} visible={!window.gitify.platform.isLinux()} /> + + toggleSetting('useX11Backend')} + tooltip={ + + Run under X11/XWayland so the window opens next to the tray icon. On Wayland the + compositor decides where windows appear, so {APPLICATION.NAME} opens in the middle of + the screen. Enabling this may soften text on displays using fractional scaling. Takes + effect after restarting {APPLICATION.NAME}. + + } + visible={window.gitify.platform.isLinux()} + /> ); diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts index 506807032..668cb8613 100644 --- a/src/renderer/stores/defaults.ts +++ b/src/renderer/stores/defaults.ts @@ -87,6 +87,7 @@ const DEFAULT_SYSTEM_SETTINGS: SystemSettingsState = { notificationVolume: 20 as Percentage, openAtStartup: false, keepWindowOnBlur: false, + useX11Backend: false, }; /** diff --git a/src/renderer/stores/subscriptions.ts b/src/renderer/stores/subscriptions.ts index 7ff3c4a59..e93d8ee33 100644 --- a/src/renderer/stores/subscriptions.ts +++ b/src/renderer/stores/subscriptions.ts @@ -10,6 +10,7 @@ import { setKeepWindowOnBlur, setUseAlternateIdleIcon, setUseUnreadActiveIcon, + setUseX11Backend, } from '../utils/system/comms'; import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/ui/zoom'; import { useSettingsStore } from './'; @@ -56,6 +57,17 @@ export function initializeStoreSubscriptions(): () => void { ); unsubscribers.push(unsubKeepWindowOnBlur); + // Linux X11 backend. Not applied on startup: the main process reads its own + // marker file before the renderer exists, so mirroring it here would be + // redundant. Only the change needs forwarding. + const unsubUseX11Backend = useSettingsStore.subscribe( + (state) => state.useX11Backend, + (useX11Backend) => { + setUseX11Backend(useX11Backend); + }, + ); + unsubscribers.push(unsubUseX11Backend); + // Tray icon settings (unread active icon) const unsubUnreadActive = useSettingsStore.subscribe( (state) => state.useUnreadActiveIcon, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 9e3316fd2..42cd75941 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -140,6 +140,8 @@ export interface SystemSettingsState { notificationVolume: Percentage; openAtStartup: boolean; keepWindowOnBlur: boolean; + /** Linux only. Runs under X11/XWayland so the popup can be anchored to the tray icon. */ + useX11Backend: boolean; } /** Values are lower-cased because they double as the root `data-theme` attribute. */ diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index 8701fecc0..a27910e98 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -91,6 +91,18 @@ export function setKeepWindowOnBlur(value: boolean): void { window.gitify.setKeepWindowOnBlur(value); } +/** + * Persist whether Linux should run under the X11 backend. + * + * The Ozone platform is fixed while the app starts, so this only takes effect + * on the next launch. + * + * @param value - `true` to force X11/XWayland, `false` to let Electron pick. + */ +export function setUseX11Backend(value: boolean): void { + window.gitify.setUseX11Backend(value); +} + /** * Switch the tray icon to an alternate idle icon variant. * diff --git a/src/shared/events.ts b/src/shared/events.ts index 3e9eec0a9..e5f60b7d5 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -17,6 +17,7 @@ export const EVENTS = { UPDATE_KEYBOARD_SHORTCUT: `${P}update-keyboard-shortcut`, UPDATE_AUTO_LAUNCH: `${P}update-auto-launch`, UPDATE_KEEP_WINDOW_ON_BLUR: `${P}update-keep-window-on-blur`, + UPDATE_USE_X11_BACKEND: `${P}update-use-x11-backend`, SET_WINDOW_VIBRANCY: `${P}set-window-vibrancy`, SET_NATIVE_THEME: `${P}set-native-theme`, SAFE_STORAGE_ENCRYPT: `${P}safe-storage-encrypt`, @@ -111,6 +112,10 @@ export type EventContracts = AssertEventCoverage<{ request: boolean; response: undefined; }; + [EVENTS.UPDATE_USE_X11_BACKEND]: { + request: boolean; + response: undefined; + }; [EVENTS.SET_WINDOW_VIBRANCY]: { request: boolean; response: undefined }; [EVENTS.SET_NATIVE_THEME]: { request: NativeThemeSource; response: undefined }; [EVENTS.SAFE_STORAGE_ENCRYPT]: { request: string; response: string }; From f6d97a51e4795feb286ce615f896f66afda802b3 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 24 Aug 2026 13:13:50 +0200 Subject: [PATCH 2/2] test(linux): cover x11 backend bridge and error paths --- src/main/handlers/system.test.ts | 18 +++++++++++++ src/main/ozone.test.ts | 31 +++++++++++++++++++++++ src/preload/index.test.ts | 9 +++++++ src/renderer/stores/subscriptions.test.ts | 4 +++ src/renderer/utils/system/comms.test.ts | 8 ++++++ 5 files changed, 70 insertions(+) diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index dbbb44306..2fb31459c 100644 --- a/src/main/handlers/system.test.ts +++ b/src/main/handlers/system.test.ts @@ -4,8 +4,13 @@ import type { Menubar } from 'electron-menubar'; import { EVENTS } from '../../shared/events'; import { applyKeepWindowOnBlur } from '../lifecycle/window'; +import { setX11Backend } from '../ozone'; import { registerSystemHandlers } from './system'; +vi.mock('../ozone', () => ({ + setX11Backend: vi.fn(), +})); + vi.mock('../lifecycle/window', () => ({ applyKeepWindowOnBlur: vi.fn(), })); @@ -160,6 +165,19 @@ describe('main/handlers/system.ts', () => { }); }); + describe('UPDATE_USE_X11_BACKEND', () => { + it('forwards the value to setX11Backend', () => { + registerSystemHandlers(menubar); + + const handler = onMock.mock.calls.find( + (call: unknown[]) => call[0] === EVENTS.UPDATE_USE_X11_BACKEND, + )?.[1]; + handler?.({}, true); + + expect(setX11Backend).toHaveBeenCalledWith(true); + }); + }); + describe('UPDATE_KEYBOARD_SHORTCUT', () => { it('delegates registration to mb.setGlobalShortcut when enabled', () => { const handler = getKeyboardShortcutHandler(); diff --git a/src/main/ozone.test.ts b/src/main/ozone.test.ts index 129834ea2..9af801432 100644 --- a/src/main/ozone.test.ts +++ b/src/main/ozone.test.ts @@ -63,6 +63,37 @@ describe('main/ozone.ts', () => { expect(() => setX11Backend(false)).not.toThrow(); expect(logErrorMock).not.toHaveBeenCalled(); }); + + it('logs and swallows a failure to write the marker', () => { + const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(() => setX11Backend(true)).not.toThrow(); + expect(logErrorMock).toHaveBeenCalledWith( + 'setX11Backend', + expect.stringContaining('Unable to persist'), + expect.anything(), + ); + + writeSpy.mockRestore(); + }); + + it('reports disabled when the marker cannot be read', () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('EIO'); + }); + + // Defaulting to "off" keeps a broken read from silently forcing X11. + expect(isX11BackendEnabled()).toBe(false); + expect(logErrorMock).toHaveBeenCalledWith( + 'isX11BackendEnabled', + expect.stringContaining('Unable to read'), + expect.anything(), + ); + + existsSpy.mockRestore(); + }); }); describe('applyOzonePlatform', () => { diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 3dd5ec7c9..994008279 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -63,6 +63,7 @@ class MockNotification { interface TestApi { tray: { updateColor: (n?: number, isOnline?: boolean) => void }; openExternalLink: (u: string, f: boolean) => void; + setUseX11Backend: (value: boolean) => void; app: { version: () => Promise; show?: () => void; hide?: () => void }; raiseNativeNotification: (t: string, b: string, u?: string) => unknown; } @@ -99,6 +100,14 @@ describe('preload/index', () => { }); }); + it('setUseX11Backend sends the preference to main', async () => { + const api = getExposedApi(); + + api.setUseX11Backend(true); + + expect(sendMainEventMock).toHaveBeenCalledWith(EVENTS.UPDATE_USE_X11_BACKEND, true); + }); + it('openExternalLink sends event with payload', async () => { const api = getExposedApi(); diff --git a/src/renderer/stores/subscriptions.test.ts b/src/renderer/stores/subscriptions.test.ts index 082daa11d..ab85ba8b6 100644 --- a/src/renderer/stores/subscriptions.test.ts +++ b/src/renderer/stores/subscriptions.test.ts @@ -14,6 +14,7 @@ describe('renderer/stores/subscriptions.ts', () => { const setUseAlternateIdleIconSpy = vi .spyOn(comms, 'setUseAlternateIdleIcon') .mockImplementation(vi.fn()); + const setUseX11BackendSpy = vi.spyOn(comms, 'setUseX11Backend').mockImplementation(vi.fn()); let cleanup: (() => void) | null = null; @@ -53,6 +54,9 @@ describe('renderer/stores/subscriptions.ts', () => { useSettingsStore.getState().updateSetting('useAlternateIdleIcon', true); expect(setUseAlternateIdleIconSpy).toHaveBeenCalledWith(true); + + useSettingsStore.getState().updateSetting('useX11Backend', true); + expect(setUseX11BackendSpy).toHaveBeenCalledWith(true); }); it('applies zoom level when zoom percentage changes', () => { diff --git a/src/renderer/utils/system/comms.test.ts b/src/renderer/utils/system/comms.test.ts index 5b969fcd7..5b14b11c0 100644 --- a/src/renderer/utils/system/comms.test.ts +++ b/src/renderer/utils/system/comms.test.ts @@ -14,6 +14,7 @@ import { setAutoLaunch, setKeepWindowOnBlur, setUseAlternateIdleIcon, + setUseX11Backend, showWindow, updateTrayColor, updateTrayTitle, @@ -120,6 +121,13 @@ describe('renderer/utils/comms.ts', () => { expect(window.gitify.setKeepWindowOnBlur).toHaveBeenCalledWith(true); }); + it('sets the X11 backend preference', () => { + setUseX11Backend(true); + + expect(window.gitify.setUseX11Backend).toHaveBeenCalledTimes(1); + expect(window.gitify.setUseX11Backend).toHaveBeenCalledWith(true); + }); + it('applies keyboard shortcut', async () => { await applyKeyboardShortcut({ enabled: true,