From e764709ea601e63bcd02d71a21b1e56696a790c4 Mon Sep 17 00:00:00 2001 From: Yuriy Matskanyuk Date: Tue, 8 Sep 2026 21:22:53 +0500 Subject: [PATCH 1/4] 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. * From ef522dfdf455ce1798e9a8d2c9bee1861875b473 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 21:01:33 +0200 Subject: [PATCH 2/4] fix: use standalone GNOME extension installation --- 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 -- .../settings/SystemSettings.test.tsx | 15 +++ .../components/settings/SystemSettings.tsx | 107 ++++------------ src/renderer/hooks/useGnomeExtension.ts | 39 ------ src/renderer/utils/system/comms.ts | 14 +-- src/shared/events.ts | 14 --- src/shared/platform.ts | 9 -- 16 files changed, 40 insertions(+), 472 deletions(-) delete mode 100644 gnome-extension/tray-position@gitify.app/extension.js delete mode 100644 gnome-extension/tray-position@gitify.app/metadata.json delete mode 100644 src/main/gnome.test.ts delete mode 100644 src/main/gnome.ts delete mode 100644 src/renderer/hooks/useGnomeExtension.ts diff --git a/electron-builder.js b/electron-builder.js index bdd152fe9..dc20ce5d4 100644 --- a/electron-builder.js +++ b/electron-builder.js @@ -60,7 +60,6 @@ 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 deleted file mode 100644 index 4b4ce725d..000000000 --- a/gnome-extension/tray-position@gitify.app/extension.js +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index b87bd4e99..000000000 --- a/gnome-extension/tray-position@gitify.app/metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 8f81c11be..000000000 --- a/src/main/gnome.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 42805445c..000000000 --- a/src/main/gnome.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 0c925af26..d2c49411a 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -5,7 +5,6 @@ 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'; @@ -102,10 +101,6 @@ 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 80b268326..0160095e8 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 { isGnome, isLinux, isMacOS, isWindows } from '../shared/platform'; +import { isLinux, isMacOS, isWindows } from '../shared/platform'; import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils'; @@ -82,13 +82,6 @@ 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. @@ -166,9 +159,6 @@ 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 494f8c1c9..3e05ceb49 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -90,15 +90,9 @@ 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 a2090255f..b172ce9d5 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -88,15 +88,9 @@ 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 eaa0ff809..ce3685af4 100644 --- a/src/renderer/components/GlobalEffects.tsx +++ b/src/renderer/components/GlobalEffects.tsx @@ -4,7 +4,6 @@ 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 { @@ -48,12 +47,6 @@ 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.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index 2a7dbb5e8..e9f483c63 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -60,6 +60,9 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { }); expect(screen.queryByTestId('checkbox-useX11Backend')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'GNOME extension installation' }), + ).not.toBeInTheDocument(); }); it('is shown and toggles on Linux', async () => { @@ -73,6 +76,18 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { expect(toggleSettingSpy).toHaveBeenCalledWith('useX11Backend'); }); + + it('opens standalone GNOME installation instructions on Linux', async () => { + isLinuxMock().mockReturnValue(true); + renderWithProviders(); + + await userEvent.click(screen.getByRole('button', { name: 'GNOME extension installation' })); + + expect(window.gitify.openExternalLink).toHaveBeenCalledWith( + 'https://github.com/gitify-app/gnome#install', + expect.any(Boolean), + ); + }); }); it('should reset global shortcut to default when customized', async () => { diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 19618deae..1b2c5813f 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -1,31 +1,20 @@ import { type FC, useEffect, useRef, useState } from 'react'; import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react'; -import { - Banner, - Button, - ButtonGroup, - IconButton, - Label, - type LabelProps, - Stack, - Text, -} from '@primer/react'; +import { Banner, Button, ButtonGroup, IconButton, 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'; +import { type KeyboardAcceleratorShortcut, OpenPreference, toLink } from '../../types'; +import { openExternalLink } from '../../utils/system/comms'; import { formatAcceleratorForDisplay, keyboardEventToAccelerator, @@ -65,13 +54,11 @@ 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) { @@ -381,79 +368,31 @@ export const SystemSettings: FC = () => { onChange={() => 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 also disables Vulkan, which crashes under X11 on some - drivers, and may soften text on displays using fractional scaling. Takes effect after - restarting {APPLICATION.NAME}. + Run under X11/XWayland so the window opens next to the tray icon. On Wayland, window + placement depends on your compositor and its extensions. Enabling this also disables + Vulkan, which crashes under X11 on some drivers, and may soften text on displays using + fractional scaling. Takes effect after restarting {APPLICATION.NAME}. } visible={window.gitify.platform.isLinux()} /> - - {isGnome && gnomeExtensionState && } + {window.gitify.platform.isLinux() && ( + + + On GNOME Wayland, install the Gitify extension to open the window next to its tray + icon. Install and manage it through GNOME's extension tools. + + + + )} ); }; - -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 deleted file mode 100644 index 37f6efc28..000000000 --- a/src/renderer/hooks/useGnomeExtension.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 9f6cabbbc..688944218 100644 --- a/src/renderer/utils/system/comms.ts +++ b/src/renderer/utils/system/comms.ts @@ -1,4 +1,4 @@ -import type { GnomeExtensionState, ISafeStorageDecryptResult } from '../../../shared/events'; +import type { ISafeStorageDecryptResult } from '../../../shared/events'; import { useSettingsStore } from '../../stores'; @@ -112,18 +112,6 @@ 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 44b0fcecd..dc74cd571 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -28,9 +28,6 @@ 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. */ @@ -81,14 +78,6 @@ 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 }; @@ -144,9 +133,6 @@ 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 cbf058b47..9f6810a2b 100644 --- a/src/shared/platform.ts +++ b/src/shared/platform.ts @@ -7,15 +7,6 @@ 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. * From e9d7ab0cd16f6ebbc135b0256577067565037db5 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 21:02:00 +0200 Subject: [PATCH 3/4] fix: show a visible idle tray icon on GNOME --- src/main/icons.test.ts | 36 +++++++++++++++++-- src/main/icons.ts | 7 +++- .../components/settings/TraySettings.tsx | 4 +-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/main/icons.test.ts b/src/main/icons.test.ts index edb0f53be..65225ed67 100644 --- a/src/main/icons.test.ts +++ b/src/main/icons.test.ts @@ -1,7 +1,20 @@ -import { TrayIcons } from './icons'; +import { isLinux } from '../shared/platform'; + +vi.mock('../shared/platform', () => ({ isLinux: vi.fn() })); describe('main/icons.ts', () => { - it('should return icon images', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(isLinux).mockReturnValue(false); + vi.stubEnv('XDG_CURRENT_DESKTOP', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should return icon images', async () => { + const { TrayIcons } = await import('./icons'); expect(TrayIcons.active).toContain('assets/images/tray-active.png'); expect(TrayIcons.idle).toContain('assets/images/tray-idleTemplate.png'); @@ -12,4 +25,23 @@ describe('main/icons.ts', () => { expect(TrayIcons.offline).toContain('assets/images/tray-offline.png'); }); + + it.each([ + [true, 'GNOME', 'tray-idle-white.png'], + [true, 'ubuntu:GNOME', 'tray-idle-white.png'], + [true, 'GNOME-Classic:GNOME', 'tray-idle-white.png'], + [true, 'gnome', 'tray-idle-white.png'], + [true, 'KDE', 'tray-idleTemplate.png'], + [true, '', 'tray-idleTemplate.png'], + [true, 'NOT-GNOME', 'tray-idleTemplate.png'], + [false, 'GNOME', 'tray-idleTemplate.png'], + ])('selects the idle icon for Linux=%s and desktop=%s', async (linux, desktop, file) => { + vi.mocked(isLinux).mockReturnValue(linux); + vi.stubEnv('XDG_CURRENT_DESKTOP', desktop); + + const { TrayIcons } = await import('./icons'); + + expect(TrayIcons.idle).toContain(`assets/images/${file}`); + expect(TrayIcons.idleAlternate).toContain('assets/images/tray-idle-white.png'); + }); }); diff --git a/src/main/icons.ts b/src/main/icons.ts index 22f17df98..cdea113d9 100644 --- a/src/main/icons.ts +++ b/src/main/icons.ts @@ -1,8 +1,13 @@ import path from 'node:path'; +import { isLinux } from '../shared/platform'; + +const isGnome = + isLinux() && (process.env.XDG_CURRENT_DESKTOP ?? '').toUpperCase().split(':').includes('GNOME'); + export const TrayIcons = { active: getIconPath('tray-active.png'), - idle: getIconPath('tray-idleTemplate.png'), + idle: getIconPath(isGnome ? 'tray-idle-white.png' : 'tray-idleTemplate.png'), idleAlternate: getIconPath('tray-idle-white.png'), error: getIconPath('tray-error.png'), offline: getIconPath('tray-offline.png'), diff --git a/src/renderer/components/settings/TraySettings.tsx b/src/renderer/components/settings/TraySettings.tsx index 66e4f67e3..196c86657 100644 --- a/src/renderer/components/settings/TraySettings.tsx +++ b/src/renderer/components/settings/TraySettings.tsx @@ -58,8 +58,8 @@ export const TraySettings: FC = () => { tooltip={ - Use a white {APPLICATION.NAME} logo (instead of the default black logo) when all - notifications are read. + Use a white {APPLICATION.NAME} logo when all notifications are read. GNOME uses this + icon by default. This is particularly useful for devices which have a dark-themed menubar or taskbar. From 2af816c85cf4ca2f9fa505063378ec0eda814d2d Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 01:41:16 +0200 Subject: [PATCH 4/4] fix: select tray icon contrast independently of the desktop Replace the GNOME-specific default with automatic, light and dark idle icon choices. Follow the Windows system theme and macOS template rendering, with an explicit light fallback on Linux. Preserve saved icon preferences and refresh immediately on preference and native theme changes without losing notification state. --- assets/images/tray-idle-black.png | Bin 0 -> 1134 bytes assets/images/tray-idle-black@2x.png | Bin 0 -> 866 bytes src/main/handlers/tray.test.ts | 62 +++++++++- src/main/handlers/tray.ts | 115 +++++++----------- src/main/icons.test.ts | 81 ++++++------ src/main/icons.ts | 25 +++- src/main/index.ts | 4 +- src/preload/index.ts | 8 +- src/renderer/__helpers__/visual.setup.ts | 2 +- src/renderer/__helpers__/vitest.setup.ts | 2 +- src/renderer/__mocks__/state-mocks.ts | 2 +- src/renderer/components/GlobalEffects.tsx | 4 +- .../components/settings/TraySettings.test.tsx | 8 +- .../components/settings/TraySettings.tsx | 35 ++++-- .../__snapshots__/Settings.test.tsx.snap | 94 ++++++++++++-- src/renderer/stores/defaults.ts | 2 +- src/renderer/stores/subscriptions.test.ts | 12 +- src/renderer/stores/subscriptions.ts | 16 +-- src/renderer/stores/useSettingsStore.test.ts | 42 +++++++ src/renderer/stores/useSettingsStore.ts | 20 +++ src/renderer/types.ts | 4 +- src/renderer/utils/system/comms.test.ts | 10 +- src/renderer/utils/system/comms.ts | 10 +- src/shared/events.ts | 10 +- 24 files changed, 385 insertions(+), 183 deletions(-) create mode 100644 assets/images/tray-idle-black.png create mode 100644 assets/images/tray-idle-black@2x.png diff --git a/assets/images/tray-idle-black.png b/assets/images/tray-idle-black.png new file mode 100644 index 0000000000000000000000000000000000000000..7ccebca068dd7204764ecee4037a2db1cff17a03 GIT binary patch literal 1134 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC=rkv;hE;^%b*2hb1*P5 z3NbJPS&Tr)(4NV_0%kKX08Ih{<^_xh*#%5+S&0SA2sTLJhZoVC85o!hGD9LtB7A+U zlJj%*5>xV%QuQiw3qT4OY$~jP%-qzHM1_jnoV;SI3R@+x3M(KRB&@Hb09I0xZL1XF z8=&BvUzDm~re~mMpk&9TprBw=l#*r@P?Wt5Z@Sn2DRmzV368|&p4rRy77 zT3YHG80i}s=>k>g7FXt#Bv$C=6)QswftllyTAW;zSx}OhpQivaH!&%{w8U0P31kr* zK-`&Hk%_P=uNdkrpa=CqGWv#k2Kv~vm1L&j&<2u$XaoBTVx0}hFsp#fiqxD)m(1MM zykel!?2Jroz_wsWAl!+hF#?xHBnfnl&iOg{MZpD$$*FdRP`i+XkW_>QTWtlwHvHsbYj%}Cq<+Cj!N(-ylaPpG_gRG%hb464_Q{`X zIy$d*Tl%t|#JV4m-?x5`tezXRvOms0VZj5|X(w(s6c=2&dS{XR0srT(J?5(a7Wt>L z<9eX`^gTWQre)-`%k7+XXqDSXuC<=?Y828p7KBf)kTI!0ey@?cKChao;PxJ2_ z3VXcHKxSnWf6eb3QmqSety(S{8QoFN{%}~P-qZ9;d_WoNbH_8E{64sc*If{uEu~f$ z&0zVYWPM)Jo2vGg8=1~V+fRKc_*BVw-2|!2;#UrP_c6gB0F2&*5QUU^?UJ;uw;_ z`gX?7Zc#^pW7nl!K1T3Ih!hG7ibh^>aJiGh?C5Ctk^ch|v!T(Il;XmJE(aY%TrN7W z3K|Ls3idk)^yd^FWft}S@a_9S9=*SDS7od}-(%kWVDbHs(E8BW%dy6vckNnqgbM=y zoEMb{zx-(V!P}D)%#tOZo9|M8V0htu--M|tg&Z~v^+k`&>h>;JyJ^mg=??dnIPb4M zGxtWHsK$%L58N%DqI=_RzhcdZk6*9wv1H-x=$#FcEY%^2pQ4Uj3e!zsYFrmG=g-M7 z)%6z5qH6Y&KKP0iMsoh%e0(#<;;Qc==Q(e?7cj-Joj+hL&{g&Sc|hd%t$C$YFV=h% zvAbKU9@JKEH+j>orQ5=fpW-vxyh`tHYgy{N@0>f1dVa9pR@+yry^S|v-NVnEe+vt) zZwOyh$JkxUeok8OibBffXT8_zkLFK3Xq>jSYXA2C-z5ag7R%2~x%NKs&vVI`WmS7J zf2Lnx{PyRH*6fqV(!AWa3*5FeIQcQtpt5u6xk$U<;NuB8Q#_2yZ68l;IKKCcm*3=g zruU1JTLmA^T6jJv+xy+ABCV|JN^5Lut|o4XJC6$mb=yH~-sKPrVIOusEr z`umWKY3s}d$|(&xeN3SyzD6=%zm^HhF#IadKQrlj> ({ + app: new EventEmitter(), + nativeTheme: Object.assign(new EventEmitter(), { + shouldUseDarkColorsForSystemIntegratedUI: false, + }), ipcMain: { on: (...args: unknown[]) => onMock(...args), } satisfies Pick, })); +vi.mock('../../shared/platform', () => ({ isMacOS: () => false, isWindows: () => true })); + describe('main/handlers/tray.ts', () => { let menubar: Menubar; beforeEach(() => { + vi.clearAllMocks(); + nativeTheme.removeAllListeners(); + app.removeAllListeners(); + Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: false }); menubar = { tray: { isDestroyed: vi.fn().mockReturnValue(false), @@ -36,7 +49,7 @@ describe('main/handlers/tray.ts', () => { const registeredEvents = onMock.mock.calls.map((call: unknown[]) => call[0]); - expect(registeredEvents).toContain(EVENTS.USE_ALTERNATE_IDLE_ICON); + expect(registeredEvents).toContain(EVENTS.SET_TRAY_ICON_APPEARANCE); expect(registeredEvents).toContain(EVENTS.USE_UNREAD_ACTIVE_ICON); expect(registeredEvents).toContain(EVENTS.UPDATE_ICON_COLOR); expect(registeredEvents).toContain(EVENTS.UPDATE_ICON_TITLE); @@ -63,7 +76,7 @@ describe('main/handlers/tray.ts', () => { )?.[1]; updateColorHandler?.({}, { notificationsCount: 0, isOnline: true }); - expect(menubar.tray.setImage).toHaveBeenCalledWith(TrayIcons.idle); + expect(menubar.tray.setImage).toHaveBeenCalledWith(TrayIcons.dark); }); it('sets active icon when notifications count is positive', () => { @@ -109,4 +122,49 @@ describe('main/handlers/tray.ts', () => { expect(menubar.tray.setTitle).toHaveBeenCalledWith('5'); }); + it('updates an idle icon when the system theme changes and honors a manual override', () => { + registerTrayHandlers(menubar); + const appearance = onMock.mock.calls.find( + (call) => call[0] === EVENTS.SET_TRAY_ICON_APPEARANCE, + )?.[1]; + Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: true }); + nativeTheme.emit('updated'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.light); + appearance({}, 'dark'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark); + nativeTheme.emit('updated'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark); + appearance({}, 'auto'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.light); + }); + + it.each([ + [{ notificationsCount: 3, isOnline: true }, TrayIcons.active], + [{ notificationsCount: -1, isOnline: true }, TrayIcons.error], + [{ notificationsCount: 0, isOnline: false }, TrayIcons.offline], + ])('preserves notification state across theme and preference changes: %j', (state, icon) => { + registerTrayHandlers(menubar); + onMock.mock.calls.find((call) => call[0] === EVENTS.UPDATE_ICON_COLOR)?.[1]({}, state); + nativeTheme.emit('updated'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(icon); + onMock.mock.calls.find((call) => call[0] === EVENTS.SET_TRAY_ICON_APPEARANCE)?.[1]({}, 'light'); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(icon); + }); + + it('applies the idle appearance immediately when unread highlighting is disabled', () => { + registerTrayHandlers(menubar); + onMock.mock.calls.find((call) => call[0] === EVENTS.UPDATE_ICON_COLOR)?.[1]( + {}, + { notificationsCount: 3, isOnline: true }, + ); + onMock.mock.calls.find((call) => call[0] === EVENTS.USE_UNREAD_ACTIVE_ICON)?.[1]({}, false); + expect(menubar.tray.setImage).toHaveBeenLastCalledWith(TrayIcons.dark); + }); + + it('removes the native theme listener on quit', () => { + registerTrayHandlers(menubar); + expect(nativeTheme.listenerCount('updated')).toBe(1); + app.emit('will-quit'); + expect(nativeTheme.listenerCount('updated')).toBe(0); + }); }); diff --git a/src/main/handlers/tray.ts b/src/main/handlers/tray.ts index 352707333..67b552a1b 100644 --- a/src/main/handlers/tray.ts +++ b/src/main/handlers/tray.ts @@ -1,87 +1,56 @@ +import { app, nativeTheme } from 'electron'; import type { Menubar } from 'electron-menubar'; -import { EVENTS, type ITrayColorUpdate } from '../../shared/events'; +import { + EVENTS, + isTrayIconAppearance, + type ITrayColorUpdate, + type TrayIconAppearance, +} from '../../shared/events'; import { onMainEvent } from '../events'; -import { TrayIcons } from '../icons'; +import { getIdleTrayIcon, TrayIcons } from '../icons'; -let shouldUseAlternateIdleIcon = false; -let shouldUseUnreadActiveIcon = true; - -function setIdleIcon(mb: Menubar): void { - if (shouldUseAlternateIdleIcon) { - mb.tray.setImage(TrayIcons.idleAlternate); - } else { - mb.tray.setImage(TrayIcons.idle); - } -} - -function setActiveIcon(mb: Menubar): void { - if (shouldUseUnreadActiveIcon) { - mb.tray.setImage(TrayIcons.active); - } else { - setIdleIcon(mb); - } -} - -function setErrorIcon(mb: Menubar): void { - mb.tray.setImage(TrayIcons.error); -} - -function setOfflineIcon(mb: Menubar): void { - mb.tray.setImage(TrayIcons.offline); -} - -/** - * Register IPC handlers for tray icon visual state. - * - * @param mb - The menubar instance whose tray is controlled. - */ export function registerTrayHandlers(mb: Menubar): void { - /** - * Toggle the alternate idle tray icon variant. - */ - onMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, (_, useAlternateIdleIcon: boolean) => { - shouldUseAlternateIdleIcon = useAlternateIdleIcon; - }); + let appearance: TrayIconAppearance = 'auto'; + let highlightUnread = true; + let status: ITrayColorUpdate = { notificationsCount: 0, isOnline: true }; - /** - * Toggle whether unread notifications show an active (coloured) tray icon. - */ - onMainEvent(EVENTS.USE_UNREAD_ACTIVE_ICON, (_, useUnreadActiveIcon: boolean) => { - shouldUseUnreadActiveIcon = useUnreadActiveIcon; - }); - - /** - * Update the tray icon based on the current notification count. - */ - onMainEvent(EVENTS.UPDATE_ICON_COLOR, (_, { notificationsCount, isOnline }: ITrayColorUpdate) => { - if (!mb.tray.isDestroyed()) { - if (!isOnline) { - setOfflineIcon(mb); - return; - } - - if (notificationsCount < 0) { - setErrorIcon(mb); - return; - } - - if (notificationsCount > 0) { - setActiveIcon(mb); - return; - } - - setIdleIcon(mb); + const refresh = () => { + if (mb.tray.isDestroyed()) { + return; + } + const { notificationsCount, isOnline } = status; + const icon = !isOnline + ? TrayIcons.offline + : notificationsCount < 0 + ? TrayIcons.error + : notificationsCount > 0 && highlightUnread + ? TrayIcons.active + : getIdleTrayIcon(appearance); + mb.tray.setImage(icon); + }; + + onMainEvent(EVENTS.SET_TRAY_ICON_APPEARANCE, (_, value) => { + if (isTrayIconAppearance(value)) { + appearance = value; + refresh(); } }); - - /** - * Update the tray icon title (notification count label on macOS). - */ - onMainEvent(EVENTS.UPDATE_ICON_TITLE, (_, title: string) => { + onMainEvent(EVENTS.USE_UNREAD_ACTIVE_ICON, (_, value) => { + highlightUnread = value; + refresh(); + }); + onMainEvent(EVENTS.UPDATE_ICON_COLOR, (_, value) => { + status = value; + refresh(); + }); + onMainEvent(EVENTS.UPDATE_ICON_TITLE, (_, title) => { if (!mb.tray.isDestroyed()) { mb.tray.setTitle(title); } }); + + nativeTheme.on('updated', refresh); + app.once('will-quit', () => nativeTheme.removeListener('updated', refresh)); } diff --git a/src/main/icons.test.ts b/src/main/icons.test.ts index 65225ed67..9b79bce14 100644 --- a/src/main/icons.test.ts +++ b/src/main/icons.test.ts @@ -1,47 +1,50 @@ -import { isLinux } from '../shared/platform'; +import { nativeTheme } from 'electron'; -vi.mock('../shared/platform', () => ({ isLinux: vi.fn() })); +import { isMacOS, isWindows } from '../shared/platform'; -describe('main/icons.ts', () => { - beforeEach(() => { - vi.resetModules(); - vi.mocked(isLinux).mockReturnValue(false); - vi.stubEnv('XDG_CURRENT_DESKTOP', ''); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('should return icon images', async () => { - const { TrayIcons } = await import('./icons'); - expect(TrayIcons.active).toContain('assets/images/tray-active.png'); - - expect(TrayIcons.idle).toContain('assets/images/tray-idleTemplate.png'); +import { getIdleTrayIcon, TrayIcons } from './icons'; - expect(TrayIcons.idleAlternate).toContain('assets/images/tray-idle-white.png'); +vi.mock('electron', () => ({ + nativeTheme: { shouldUseDarkColorsForSystemIntegratedUI: false, shouldUseDarkColors: false }, +})); +vi.mock('../shared/platform', () => ({ isMacOS: vi.fn(), isWindows: vi.fn() })); - expect(TrayIcons.error).toContain('assets/images/tray-error.png'); - - expect(TrayIcons.offline).toContain('assets/images/tray-offline.png'); +describe('tray icon appearance', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); + vi.mocked(isWindows).mockReturnValue(false); }); - it.each([ - [true, 'GNOME', 'tray-idle-white.png'], - [true, 'ubuntu:GNOME', 'tray-idle-white.png'], - [true, 'GNOME-Classic:GNOME', 'tray-idle-white.png'], - [true, 'gnome', 'tray-idle-white.png'], - [true, 'KDE', 'tray-idleTemplate.png'], - [true, '', 'tray-idleTemplate.png'], - [true, 'NOT-GNOME', 'tray-idleTemplate.png'], - [false, 'GNOME', 'tray-idleTemplate.png'], - ])('selects the idle icon for Linux=%s and desktop=%s', async (linux, desktop, file) => { - vi.mocked(isLinux).mockReturnValue(linux); - vi.stubEnv('XDG_CURRENT_DESKTOP', desktop); - - const { TrayIcons } = await import('./icons'); - - expect(TrayIcons.idle).toContain(`assets/images/${file}`); - expect(TrayIcons.idleAlternate).toContain('assets/images/tray-idle-white.png'); + it('uses a template only for automatic macOS appearance', () => { + vi.mocked(isMacOS).mockReturnValue(true); + expect(getIdleTrayIcon('auto')).toBe(TrayIcons.idle); + expect(getIdleTrayIcon('light')).toBe(TrayIcons.light); + expect(getIdleTrayIcon('dark')).toBe(TrayIcons.dark); + expect(TrayIcons.dark).not.toContain('Template'); }); + + it.each([true, false])( + 'follows Windows taskbar theme, regardless of app dark mode %s', + (appDark) => { + vi.mocked(isWindows).mockReturnValue(true); + Object.assign(nativeTheme, { + shouldUseDarkColors: appDark, + shouldUseDarkColorsForSystemIntegratedUI: true, + }); + expect(getIdleTrayIcon('auto')).toBe(TrayIcons.light); + Object.assign(nativeTheme, { shouldUseDarkColorsForSystemIntegratedUI: false }); + expect(getIdleTrayIcon('auto')).toBe(TrayIcons.dark); + expect(getIdleTrayIcon('light')).toBe(TrayIcons.light); + }, + ); + + it.each(['GNOME', 'KDE', ''])( + 'uses the Linux fallback and respects overrides on %s', + (desktop) => { + vi.stubEnv('XDG_CURRENT_DESKTOP', desktop); + expect(getIdleTrayIcon('auto')).toBe(TrayIcons.light); + expect(getIdleTrayIcon('dark')).toBe(TrayIcons.dark); + vi.unstubAllEnvs(); + }, + ); }); diff --git a/src/main/icons.ts b/src/main/icons.ts index cdea113d9..548ff0412 100644 --- a/src/main/icons.ts +++ b/src/main/icons.ts @@ -1,18 +1,33 @@ import path from 'node:path'; -import { isLinux } from '../shared/platform'; +import { nativeTheme } from 'electron'; -const isGnome = - isLinux() && (process.env.XDG_CURRENT_DESKTOP ?? '').toUpperCase().split(':').includes('GNOME'); +import type { TrayIconAppearance } from '../shared/events'; +import { isMacOS, isWindows } from '../shared/platform'; export const TrayIcons = { active: getIconPath('tray-active.png'), - idle: getIconPath(isGnome ? 'tray-idle-white.png' : 'tray-idleTemplate.png'), - idleAlternate: getIconPath('tray-idle-white.png'), + idle: getIconPath('tray-idleTemplate.png'), + light: getIconPath('tray-idle-white.png'), + dark: getIconPath('tray-idle-black.png'), error: getIconPath('tray-error.png'), offline: getIconPath('tray-offline.png'), }; +export function getIdleTrayIcon(appearance: TrayIconAppearance): string { + if (appearance !== 'auto') { + return TrayIcons[appearance]; + } + if (isMacOS()) { + return TrayIcons.idle; + } + if (isWindows()) { + return nativeTheme.shouldUseDarkColorsForSystemIntegratedUI ? TrayIcons.light : TrayIcons.dark; + } + // Linux does not expose the panel's colour scheme through Electron. + return TrayIcons.light; +} + function getIconPath(iconName: string) { return path.resolve(__dirname, 'assets', 'images', iconName); } diff --git a/src/main/index.ts b/src/main/index.ts index 3d403932c..f790e6797 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,7 @@ import { registerTrayHandlers, registerUpdaterHandlers, } from './handlers'; -import { TrayIcons } from './icons'; +import { getIdleTrayIcon } from './icons'; import { configureWindowEvents, handleProtocolURL, @@ -33,7 +33,7 @@ if (!app.isPackaged) { } const mb = menubar({ - icon: TrayIcons.idle, + icon: getIdleTrayIcon('auto'), index: Paths.indexHtml, browserWindow: WindowConfig, preloadWindow: true, diff --git a/src/preload/index.ts b/src/preload/index.ts index 0160095e8..a97af8728 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, webFrame } from 'electron'; -import type { IKeyboardShortcut, NativeThemeSource } from '../shared/events'; +import type { IKeyboardShortcut, NativeThemeSource, TrayIconAppearance } from '../shared/events'; import { EVENTS } from '../shared/events'; import { isLinux, isMacOS, isWindows } from '../shared/platform'; @@ -126,11 +126,11 @@ export const api = { updateTitle: (title = '') => sendMainEvent(EVENTS.UPDATE_ICON_TITLE, title), /** - * Switch the tray icon to an alternate idle icon variant. + * Set the idle tray icon appearance independently of the app theme. * - * @param value - `true` to use the alternate idle icon, `false` for the default. */ - useAlternateIdleIcon: (value: boolean) => sendMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, value), + setAppearance: (value: TrayIconAppearance) => + sendMainEvent(EVENTS.SET_TRAY_ICON_APPEARANCE, value), /** * Switch the tray icon to an "active" variant when there are unread notifications. diff --git a/src/renderer/__helpers__/visual.setup.ts b/src/renderer/__helpers__/visual.setup.ts index 3e05ceb49..accb6511b 100644 --- a/src/renderer/__helpers__/visual.setup.ts +++ b/src/renderer/__helpers__/visual.setup.ts @@ -100,7 +100,7 @@ function createGitifyBridgeApi(): Window['gitify'] { tray: { updateColor: vi.fn(), updateTitle: vi.fn(), - useAlternateIdleIcon: vi.fn(), + setAppearance: vi.fn(), useUnreadActiveIcon: vi.fn(), }, notificationSoundPath: vi.fn(), diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index b172ce9d5..9793acf64 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -98,7 +98,7 @@ function createGitifyBridgeApi(): Window['gitify'] { tray: { updateColor: vi.fn(), updateTitle: vi.fn(), - useAlternateIdleIcon: vi.fn(), + setAppearance: vi.fn(), useUnreadActiveIcon: vi.fn(), }, notificationSoundPath: vi.fn(), diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 2bf7c5162..5506b49e3 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -51,7 +51,7 @@ const mockNotificationSettings: NotificationSettingsState = { const mockTraySettings: TraySettingsState = { showNotificationsCountInTray: true, useUnreadActiveIcon: true, - useAlternateIdleIcon: false, + trayIconAppearance: 'auto', }; const mockSystemSettings: SystemSettingsState = { diff --git a/src/renderer/components/GlobalEffects.tsx b/src/renderer/components/GlobalEffects.tsx index ce3685af4..cf028a115 100644 --- a/src/renderer/components/GlobalEffects.tsx +++ b/src/renderer/components/GlobalEffects.tsx @@ -31,7 +31,7 @@ export const GlobalEffects: FC = () => { // Subscribe to tray-related settings for useEffect dependencies const showNotificationsCountInTray = useSettingsStore((s) => s.showNotificationsCountInTray); const useUnreadActiveIcon = useSettingsStore((s) => s.useUnreadActiveIcon); - const useAlternateIdleIcon = useSettingsStore((s) => s.useAlternateIdleIcon); + const trayIconAppearance = useSettingsStore((s) => s.trayIconAppearance); const isOnline = useOnlineStatus(); @@ -54,7 +54,7 @@ export const GlobalEffects: FC = () => { }, [ showNotificationsCountInTray, useUnreadActiveIcon, - useAlternateIdleIcon, + trayIconAppearance, status, notificationCount, isOnline, diff --git a/src/renderer/components/settings/TraySettings.test.tsx b/src/renderer/components/settings/TraySettings.test.tsx index a1ee66cd5..53fa38055 100644 --- a/src/renderer/components/settings/TraySettings.test.tsx +++ b/src/renderer/components/settings/TraySettings.test.tsx @@ -21,11 +21,17 @@ describe('renderer/components/settings/TraySettings.tsx', () => { it.each([ ['checkbox-showNotificationsCountInTray', 'showNotificationsCountInTray'], ['checkbox-useUnreadActiveIcon', 'useUnreadActiveIcon'], - ['checkbox-useAlternateIdleIcon', 'useAlternateIdleIcon'], ] as const)('should toggle %s checkbox', async (testId, setting) => { await userEvent.click(screen.getByTestId(testId)); expect(toggleSettingSpy).toHaveBeenCalledTimes(1); expect(toggleSettingSpy).toHaveBeenCalledWith(setting); }); + it.each(['auto', 'light', 'dark'] as const)( + 'selects the %s icon appearance', + async (appearance) => { + await userEvent.click(screen.getByTestId(`radio-trayIconAppearance-${appearance}`)); + expect(useSettingsStore.getState().trayIconAppearance).toBe(appearance); + }, + ); }); diff --git a/src/renderer/components/settings/TraySettings.tsx b/src/renderer/components/settings/TraySettings.tsx index 196c86657..f74065f63 100644 --- a/src/renderer/components/settings/TraySettings.tsx +++ b/src/renderer/components/settings/TraySettings.tsx @@ -4,20 +4,24 @@ import { DevicesIcon } from '@primer/octicons-react'; import { Stack, Text } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; +import { isTrayIconAppearance } from '../../../shared/events'; import { useSettingsStore } from '../../stores'; import { Checkbox } from '../fields/Checkbox'; +import { RadioGroup } from '../fields/RadioGroup'; import { Title } from '../primitives/Title'; export const TraySettings: FC = () => { + const updateSetting = useSettingsStore((s) => s.updateSetting); + // Setting store actions const toggleSetting = useSettingsStore((s) => s.toggleSetting); // Setting store values const showNotificationsCountInTray = useSettingsStore((s) => s.showNotificationsCountInTray); const useUnreadActiveIcon = useSettingsStore((s) => s.useUnreadActiveIcon); - const useAlternateIdleIcon = useSettingsStore((s) => s.useAlternateIdleIcon); + const trayIconAppearance = useSettingsStore((s) => s.trayIconAppearance); return (
@@ -50,20 +54,29 @@ export const TraySettings: FC = () => { } /> - toggleSetting('useAlternateIdleIcon')} + { + const value = event.target.value; + if (isTrayIconAppearance(value)) { + updateSetting('trayIconAppearance', value); + } + }} tooltip={ + Choose a light icon for a dark panel, or a dark icon for a light panel. - Use a white {APPLICATION.NAME} logo when all notifications are read. GNOME uses this - icon by default. - - - This is particularly useful for devices which have a dark-themed menubar or taskbar. + Automatic follows the system tray appearance on macOS and Windows. On Linux, it uses + a light icon because panel appearance cannot be detected reliably. + This setting is independent of Gitify’s app theme. } /> diff --git a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index 91e99a220..d31704f15 100644 --- a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap @@ -1561,24 +1561,94 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] data-padding="none" data-wrap="nowrap" > - +
+ + +
+
+ + +
+
+ + +