Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/main/handlers/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
128 changes: 128 additions & 0 deletions src/main/ozone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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();
});

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', () => {
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();
});
});
});
68 changes: 68 additions & 0 deletions src/main/ozone.ts
Original file line number Diff line number Diff line change
@@ -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');
}
9 changes: 9 additions & 0 deletions src/preload/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>; show?: () => void; hide?: () => void };
raiseNativeNotification: (t: string, b: string, u?: string) => unknown;
}
Expand Down Expand Up @@ -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();

Expand Down
8 changes: 8 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const mockSystemSettings: SystemSettingsState = {
notificationVolume: 20 as Percentage,
openAtStartup: false,
keepWindowOnBlur: false,
useX11Backend: false,
};

export const mockSettings: SettingsState = {
Expand Down
28 changes: 28 additions & 0 deletions src/renderer/components/settings/SystemSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;

it('is hidden off Linux', async () => {
isLinuxMock().mockReturnValue(false);

await act(async () => {
renderWithProviders(<SystemSettings />);
});

expect(screen.queryByTestId('checkbox-useX11Backend')).not.toBeInTheDocument();
});

it('is shown and toggles on Linux', async () => {
isLinuxMock().mockReturnValue(true);

await act(async () => {
renderWithProviders(<SystemSettings />);
});

await userEvent.click(screen.getByTestId('checkbox-useX11Backend'));

expect(toggleSettingSpy).toHaveBeenCalledWith('useX11Backend');
});
});

it('should reset global shortcut to default when customized', async () => {
renderWithProviders(<SystemSettings />, {
settings: {
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/components/settings/SystemSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -343,6 +344,22 @@ export const SystemSettings: FC = () => {
tooltip={<Text>Launch {APPLICATION.NAME} automatically at startup.</Text>}
visible={!window.gitify.platform.isLinux()}
/>

<Checkbox
checked={useX11Backend}
label="Use X11 backend (restart required)"
name="useX11Backend"
onChange={() => toggleSetting('useX11Backend')}
tooltip={
<Text>
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}.
</Text>
}
visible={window.gitify.platform.isLinux()}
/>
</Stack>
</fieldset>
);
Expand Down
Loading