From e764709ea601e63bcd02d71a21b1e56696a790c4 Mon Sep 17 00:00:00 2001 From: Yuriy Matskanyuk Date: Tue, 8 Sep 2026 21:22:53 +0500 Subject: [PATCH] feat: Bundle and install Gnome extension for correct window position --- electron-builder.js | 1 + .../tray-position@gitify.app/extension.js | 74 +++++++++++ .../tray-position@gitify.app/metadata.json | 8 ++ src/main/gnome.test.ts | 119 ++++++++++++++++++ src/main/gnome.ts | 76 +++++++++++ src/main/handlers/system.ts | 5 + src/preload/index.ts | 12 +- src/renderer/__helpers__/visual.setup.ts | 6 + src/renderer/__helpers__/vitest.setup.ts | 6 + src/renderer/components/GlobalEffects.tsx | 7 ++ .../components/settings/SystemSettings.tsx | 79 +++++++++++- src/renderer/hooks/useGnomeExtension.ts | 39 ++++++ src/renderer/utils/system/comms.ts | 14 ++- src/shared/events.ts | 14 +++ src/shared/platform.ts | 9 ++ 15 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 gnome-extension/tray-position@gitify.app/extension.js create mode 100644 gnome-extension/tray-position@gitify.app/metadata.json create mode 100644 src/main/gnome.test.ts create mode 100644 src/main/gnome.ts create mode 100644 src/renderer/hooks/useGnomeExtension.ts diff --git a/electron-builder.js b/electron-builder.js index dc20ce5d4..bdd152fe9 100644 --- a/electron-builder.js +++ b/electron-builder.js @@ -60,6 +60,7 @@ const config = { target: ['AppImage', 'deb', 'rpm'], category: 'Development', maintainer: 'Gitify Team', + extraResources: ['gnome-extension/**/*'], }, publish: { provider: 'github', diff --git a/gnome-extension/tray-position@gitify.app/extension.js b/gnome-extension/tray-position@gitify.app/extension.js new file mode 100644 index 000000000..4b4ce725d --- /dev/null +++ b/gnome-extension/tray-position@gitify.app/extension.js @@ -0,0 +1,74 @@ +import GLib from 'gi://GLib'; +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const MARGIN = 8; + +const REAPPLY_DELAYS_MS = [0, 60, 180, 400]; + +export default class GitifyWindowPlacementExtension extends Extension { + enable() { + this._timeouts = new Set(); + this._mapId = global.window_manager.connect('map', (_wm, actor) => + this._onWindowMapped(actor.meta_window), + ); + } + + disable() { + global.window_manager.disconnect(this._mapId); + this._mapId = null; + + for (const id of this._timeouts) { + GLib.Source.remove(id); + } + this._timeouts = null; + } + + _onWindowMapped(window) { + if (!isGitifyWindow(window)) { + return; + } + + for (const delay of REAPPLY_DELAYS_MS) { + const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { + this._timeouts.delete(id); + this._place(window); + return GLib.SOURCE_REMOVE; + }); + this._timeouts.add(id); + } + } + + _place(window) { + const workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + const frame = window.get_frame_rect(); + const maxX = workArea.x + workArea.width - frame.width - MARGIN; + const icon = trayIconRect(); + + const x = + icon === null + ? maxX + : clamp(Math.round(icon.get_center().x - frame.width / 2), workArea.x + MARGIN, maxX); + + window.move_frame(false, x, workArea.y + MARGIN); + } +} + +function isGitifyWindow(window) { + const wmClass = window?.get_wm_class()?.toLowerCase() ?? ''; + + return wmClass.includes('gitify') || (wmClass === 'electron' && window.get_title() === 'Gitify'); +} + +function trayIconRect() { + const indicator = Object.values(Main.panel.statusArea).find( + (item) => item?._indicator?.id?.toLowerCase() === 'gitify', + ); + const actor = indicator?.container ?? indicator; + + return actor?.visible ? actor.get_transformed_extents() : null; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} diff --git a/gnome-extension/tray-position@gitify.app/metadata.json b/gnome-extension/tray-position@gitify.app/metadata.json new file mode 100644 index 000000000..b87bd4e99 --- /dev/null +++ b/gnome-extension/tray-position@gitify.app/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "tray-position@gitify.app", + "name": "Gitify Window Placement", + "description": "Places the Gitify window below its tray icon.", + "shell-version": ["50"], + "url": "https://github.com/gitify-app/gitify", + "version": 1 +} diff --git a/src/main/gnome.test.ts b/src/main/gnome.test.ts new file mode 100644 index 000000000..8f81c11be --- /dev/null +++ b/src/main/gnome.test.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; + +import { enableExtension, getExtensionState, installExtension } from './gnome'; + +const UUID = 'tray-position@gitify.app'; + +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})); + +vi.mock('node:util', () => ({ + promisify: () => execFileMock, +})); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/app', + getPath: () => '/home/user', + }, +})); + +const logErrorMock = vi.fn(); +vi.mock('../shared/logger', () => ({ + logError: (...args: unknown[]) => logErrorMock(...args), + toError: (err: unknown) => err, +})); + +function mockExtensionsCli(replies: Record): void { + execFileMock.mockImplementation((_cmd: string, args: string[]) => { + const key = args.join(' '); + + return key in replies + ? Promise.resolve({ stdout: replies[key], stderr: '' }) + : Promise.reject(new Error(`unexpected call: ${key}`)); + }); +} + +describe('main/gnome.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + }); + + describe('getExtensionState', () => { + it('reports not-installed when the extension directory is missing', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + + await expect(getExtensionState()).resolves.toBe('not-installed'); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('reports pending-session-restart when the shell does not know the extension', async () => { + mockExtensionsCli({}); + + await expect(getExtensionState()).resolves.toBe('pending-session-restart'); + }); + + it('reports active when the extension is listed as active', async () => { + mockExtensionsCli({ + [`info ${UUID}`]: '', + 'list --user --active --quiet': `other@example.com\n${UUID}\n`, + }); + + await expect(getExtensionState()).resolves.toBe('active'); + }); + + it('reports inactive when the shell knows the extension but it is not active', async () => { + mockExtensionsCli({ + [`info ${UUID}`]: '', + 'list --user --active --quiet': 'other@example.com\n', + }); + + await expect(getExtensionState()).resolves.toBe('inactive'); + }); + }); + + describe('installExtension', () => { + it('copies the bundled extension into the user extension directory', async () => { + const cp = vi.spyOn(fs.promises, 'cp').mockResolvedValue(undefined); + mockExtensionsCli({}); + + await expect(installExtension()).resolves.toBe('pending-session-restart'); + expect(cp).toHaveBeenCalledWith( + `/app/gnome-extension/${UUID}`, + `/home/user/.local/share/gnome-shell/extensions/${UUID}`, + { recursive: true }, + ); + }); + + it('reports an error when the copy fails', async () => { + vi.spyOn(fs.promises, 'cp').mockRejectedValue(new Error('read-only')); + + await expect(installExtension()).resolves.toBe('error'); + expect(logErrorMock).toHaveBeenCalled(); + }); + }); + + describe('enableExtension', () => { + it('enables the extension and returns the resulting state', async () => { + mockExtensionsCli({ + [`enable ${UUID}`]: '', + [`info ${UUID}`]: '', + 'list --user --active --quiet': `${UUID}\n`, + }); + + await expect(enableExtension()).resolves.toBe('active'); + }); + + it('reports the unchanged state when enabling fails', async () => { + mockExtensionsCli({}); + + await expect(enableExtension()).resolves.toBe('pending-session-restart'); + expect(logErrorMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/gnome.ts b/src/main/gnome.ts new file mode 100644 index 000000000..42805445c --- /dev/null +++ b/src/main/gnome.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { app } from 'electron'; + +import type { GnomeExtensionState } from '../shared/events'; +import { logError, toError } from '../shared/logger'; + +const UUID = 'tray-position@gitify.app'; + +const run = promisify(execFile); + +function installPath(): string { + const dataHome = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share'); + + return path.join(dataHome, 'gnome-shell', 'extensions', UUID); +} + +function bundlePath(): string { + const root = app.isPackaged ? process.resourcesPath : app.getAppPath(); + + return path.join(root, 'gnome-extension', UUID); +} + +function isKnownToShell(): Promise { + return run('gnome-extensions', ['info', UUID]).then( + () => true, + () => false, + ); +} + +function isActive(): Promise { + return run('gnome-extensions', ['list', '--user', '--active', '--quiet']).then( + ({ stdout }) => stdout.split('\n').includes(UUID), + () => false, + ); +} + +/** @returns State of the bundled extension in the running shell. */ +export async function getExtensionState(): Promise { + if (!fs.existsSync(installPath())) { + return 'not-installed'; + } + + // The shell only learns of new extensions at session start. + if (!(await isKnownToShell())) { + return 'pending-session-restart'; + } + + return (await isActive()) ? 'active' : 'inactive'; +} + +/** @returns State after copying the bundled extension into place and enabling it. */ +export async function installExtension(): Promise { + try { + await fs.promises.cp(bundlePath(), installPath(), { recursive: true }); + } catch (err) { + logError('gnome:installExtension', 'Unable to install the extension', toError(err)); + return 'error'; + } + + // Enabling fails until the shell has loaded the extension, hence the retry + // offered by the `inactive` state. + return enableExtension(); +} + +/** @returns State after asking GNOME to enable the extension. */ +export async function enableExtension(): Promise { + await run('gnome-extensions', ['enable', UUID]).catch((err) => + logError('gnome:enableExtension', 'Unable to enable the extension', toError(err)), + ); + + return getExtensionState(); +} diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index d2c49411a..0c925af26 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -5,6 +5,7 @@ import { EVENTS } from '../../shared/events'; import { logInfo } from '../../shared/logger'; import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; +import { enableExtension, getExtensionState, installExtension } from '../gnome'; import { applyKeepWindowOnBlur, applyWindowVibrancy } from '../lifecycle/window'; import { setX11Backend } from '../ozone'; import { isDevMode } from '../utils'; @@ -101,6 +102,10 @@ export function registerSystemHandlers(mb: Menubar): void { setX11Backend(value); }); + handleMainEvent(EVENTS.GNOME_EXTENSION_STATE, () => getExtensionState()); + handleMainEvent(EVENTS.GNOME_EXTENSION_INSTALL, () => installExtension()); + handleMainEvent(EVENTS.GNOME_EXTENSION_ENABLE, () => enableExtension()); + /** * 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/preload/index.ts b/src/preload/index.ts index 0160095e8..80b268326 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import { contextBridge, webFrame } from 'electron'; import type { IKeyboardShortcut, NativeThemeSource } from '../shared/events'; import { EVENTS } from '../shared/events'; -import { isLinux, isMacOS, isWindows } from '../shared/platform'; +import { isGnome, isLinux, isMacOS, isWindows } from '../shared/platform'; import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils'; @@ -82,6 +82,13 @@ export const api = { */ setUseX11Backend: (value: boolean) => sendMainEvent(EVENTS.UPDATE_USE_X11_BACKEND, value), + /** GNOME Shell extension that places the window below the tray icon. */ + gnomeExtension: { + getState: () => invokeMainEvent(EVENTS.GNOME_EXTENSION_STATE), + install: () => invokeMainEvent(EVENTS.GNOME_EXTENSION_INSTALL), + enable: () => invokeMainEvent(EVENTS.GNOME_EXTENSION_ENABLE), + }, + /** * 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. @@ -159,6 +166,9 @@ export const api = { /** Returns `true` when running on Linux. */ isLinux: () => isLinux(), + /** Returns `true` when the desktop session is GNOME Shell. */ + isGnome: () => isGnome(), + /** Returns `true` when running on macOS. */ isMacOS: () => isMacOS(), diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index 3e05ceb49..494f8c1c9 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -90,9 +90,15 @@ function createGitifyBridgeApi(): Window['gitify'] { setNativeTheme: vi.fn().mockResolvedValue(undefined), platform: { isLinux: vi.fn().mockReturnValue(true), + isGnome: vi.fn().mockReturnValue(false), isMacOS: vi.fn().mockReturnValue(false), isWindows: vi.fn().mockReturnValue(false), }, + gnomeExtension: { + getState: vi.fn().mockResolvedValue('not-installed'), + install: vi.fn().mockResolvedValue('pending-session-restart'), + enable: vi.fn().mockResolvedValue('active'), + }, zoom: { getLevel: vi.fn(), setLevel: vi.fn(), diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index b172ce9d5..a2090255f 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -88,9 +88,15 @@ function createGitifyBridgeApi(): Window['gitify'] { setNativeTheme: vi.fn().mockResolvedValue(undefined), platform: { isLinux: vi.fn().mockReturnValue(false), + isGnome: vi.fn().mockReturnValue(false), isMacOS: vi.fn().mockReturnValue(true), isWindows: vi.fn().mockReturnValue(false), }, + gnomeExtension: { + getState: vi.fn().mockResolvedValue('not-installed'), + install: vi.fn().mockResolvedValue('pending-session-restart'), + enable: vi.fn().mockResolvedValue('active'), + }, zoom: { getLevel: vi.fn(), setLevel: vi.fn(), diff --git a/src/renderer/components/GlobalEffects.tsx b/src/renderer/components/GlobalEffects.tsx index ce3685af4..eaa0ff809 100644 --- a/src/renderer/components/GlobalEffects.tsx +++ b/src/renderer/components/GlobalEffects.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useAccounts } from '../hooks/useAccounts'; import { useAppearance } from '../hooks/useAppearance'; +import { useGnomeExtensionStore } from '../hooks/useGnomeExtension'; import { useNotifications } from '../hooks/useNotifications'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { @@ -47,6 +48,12 @@ export const GlobalEffects: FC = () => { // Global keyboard shortcut registration, reverting on failure useShortcutRegistration(); + const refreshGnomeExtension = useGnomeExtensionStore((s) => s.refresh); + + useEffect(() => { + refreshGnomeExtension(); + }, [refreshGnomeExtension]); + // oxlint-disable-next-line react/exhaustive-deps -- We want to update the tray on setting or notification changes useEffect(() => { const trayCount = status === 'error' ? -1 : notificationCount; diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 01977263d..19618deae 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -1,15 +1,27 @@ import { type FC, useEffect, useRef, useState } from 'react'; import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react'; -import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; +import { + Banner, + Button, + ButtonGroup, + IconButton, + Label, + type LabelProps, + Stack, + Text, +} from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; +import type { GnomeExtensionState } from '../../../shared/events'; +import { useGnomeExtensionStore } from '../../hooks/useGnomeExtension'; import { useShortcutRegistrationStore } from '../../hooks/useShortcutRegistration'; import { DEFAULT_SETTINGS_STATE, useSettingsStore } from '../../stores'; import { Checkbox } from '../fields/Checkbox'; import { RadioGroup } from '../fields/RadioGroup'; +import { Tooltip } from '../fields/Tooltip'; import { Title } from '../primitives/Title'; import { type KeyboardAcceleratorShortcut, OpenPreference } from '../../types'; @@ -53,11 +65,13 @@ export const SystemSettings: FC = () => { const openAtStartup = useSettingsStore((s) => s.openAtStartup); const showUpdateNotifications = useSettingsStore((s) => s.showUpdateNotifications); const useX11Backend = useSettingsStore((s) => s.useX11Backend); + const gnomeExtensionState = useGnomeExtensionStore((s) => s.state); const [recordingShortcut, setRecordingShortcut] = useState(false); const [liveModifierAccelerator, setLiveModifierAccelerator] = useState(''); const shortcutRowRef = useRef(null); const isMac = window.gitify.platform.isMacOS(); + const isGnome = window.gitify.platform.isGnome(); useEffect(() => { if (!recordingShortcut) { @@ -376,7 +390,70 @@ export const SystemSettings: FC = () => { } visible={window.gitify.platform.isLinux()} /> + + {isGnome && gnomeExtensionState && } ); }; + +const GNOME_EXTENSION_STATUS: Record< + GnomeExtensionState, + { text: string; variant: LabelProps['variant'] } +> = { + 'not-installed': { text: 'Not installed', variant: 'secondary' }, + 'pending-session-restart': { + text: 'Restart session to activate', + variant: 'attention', + }, + inactive: { text: 'Disabled', variant: 'attention' }, + active: { text: 'Active', variant: 'success' }, + error: { text: 'Failed', variant: 'danger' }, +}; + +const GNOME_EXTENSION_ACTIONS: Partial< + Record +> = { + 'not-installed': { text: 'Install', action: 'install' }, + error: { text: 'Retry', action: 'install' }, + inactive: { text: 'Enable', action: 'enable' }, +}; + +const GnomeExtensionRow: FC<{ state: GnomeExtensionState }> = ({ state }) => { + const { busy, install, enable } = useGnomeExtensionStore(); + const action = GNOME_EXTENSION_ACTIONS[state]; + + return ( + + GNOME extension + + + Wayland only lets the compositor position windows - GNOME extension anchors the{' '} + {APPLICATION.NAME} window to the tray icon. + + } + /> + + + {action && ( + + )} + + ); +}; diff --git a/src/renderer/hooks/useGnomeExtension.ts b/src/renderer/hooks/useGnomeExtension.ts new file mode 100644 index 000000000..37f6efc28 --- /dev/null +++ b/src/renderer/hooks/useGnomeExtension.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; + +import type { GnomeExtensionState } from '../../shared/events'; + +import { + enableGnomeExtension, + getGnomeExtensionState, + installGnomeExtension, +} from '../utils/system/comms'; + +interface GnomeExtensionStore { + state: GnomeExtensionState | null; + busy: boolean; + + refresh: () => Promise; + install: () => Promise; + enable: () => Promise; +} + +export const useGnomeExtensionStore = create()((set) => { + const apply = async (action: () => Promise) => { + set({ busy: true }); + set({ state: await action(), busy: false }); + }; + + return { + state: null, + busy: false, + + refresh: async () => { + if (window.gitify.platform.isGnome()) { + set({ state: await getGnomeExtensionState() }); + } + }, + + install: () => apply(installGnomeExtension), + enable: () => apply(enableGnomeExtension), + }; +}); diff --git a/src/renderer/utils/system/comms.ts b/src/renderer/utils/system/comms.ts index 688944218..9f6cabbbc 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -1,4 +1,4 @@ -import type { ISafeStorageDecryptResult } from '../../../shared/events'; +import type { GnomeExtensionState, ISafeStorageDecryptResult } from '../../../shared/events'; import { useSettingsStore } from '../../stores'; @@ -112,6 +112,18 @@ export function setUseX11Backend(value: boolean): void { window.gitify.setUseX11Backend(value); } +export function getGnomeExtensionState(): Promise { + return window.gitify.gnomeExtension.getState(); +} + +export function installGnomeExtension(): Promise { + return window.gitify.gnomeExtension.install(); +} + +export function enableGnomeExtension(): Promise { + return window.gitify.gnomeExtension.enable(); +} + /** * Switch the tray icon to an alternate idle icon variant. * diff --git a/src/shared/events.ts b/src/shared/events.ts index dc74cd571..44b0fcecd 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -28,6 +28,9 @@ export const EVENTS = { RESET_APP: `${P}reset-app`, TWEMOJI_DIRECTORY: `${P}twemoji-directory`, SYSTEM_WAKE: `${P}system-wake`, + GNOME_EXTENSION_STATE: `${P}gnome-extension-state`, + GNOME_EXTENSION_INSTALL: `${P}gnome-extension-install`, + GNOME_EXTENSION_ENABLE: `${P}gnome-extension-enable`, } as const; /** Union type of all valid IPC event name strings. */ @@ -78,6 +81,14 @@ export interface ISafeStorageDecryptResult { reEncryptedToken?: string; } +/** State of the GNOME Shell extension that places the window below the tray icon. */ +export type GnomeExtensionState = + | 'not-installed' + | 'pending-session-restart' + | 'inactive' + | 'active' + | 'error'; + /** Shape of a single event contract: a request payload and a response payload. */ type Contract = { request: unknown; response: unknown }; @@ -133,6 +144,9 @@ export type EventContracts = AssertEventCoverage<{ [EVENTS.RESET_APP]: { request: undefined; response: undefined }; [EVENTS.TWEMOJI_DIRECTORY]: { request: undefined; response: string }; [EVENTS.SYSTEM_WAKE]: { request: undefined; response: undefined }; + [EVENTS.GNOME_EXTENSION_STATE]: { request: undefined; response: GnomeExtensionState }; + [EVENTS.GNOME_EXTENSION_INSTALL]: { request: undefined; response: GnomeExtensionState }; + [EVENTS.GNOME_EXTENSION_ENABLE]: { request: undefined; response: GnomeExtensionState }; }>; /** Request payload type for a given event. */ diff --git a/src/shared/platform.ts b/src/shared/platform.ts index 9f6810a2b..cbf058b47 100644 --- a/src/shared/platform.ts +++ b/src/shared/platform.ts @@ -7,6 +7,15 @@ export function isLinux(): boolean { return process.platform === 'linux'; } +/** + * Returns `true` if the desktop session is GNOME Shell. + * + * @returns `true` under GNOME, `false` otherwise. + */ +export function isGnome(): boolean { + return isLinux() && (process.env.XDG_CURRENT_DESKTOP ?? '').toUpperCase().includes('GNOME'); +} + /** * Returns `true` if the current operating system is macOS. *