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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const config = {
target: ['AppImage', 'deb', 'rpm'],
category: 'Development',
maintainer: 'Gitify Team',
extraResources: ['gnome-extension/**/*'],
},
publish: {
provider: 'github',
Expand Down
74 changes: 74 additions & 0 deletions gnome-extension/tray-position@gitify.app/extension.js
Original file line number Diff line number Diff line change
@@ -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));
}
8 changes: 8 additions & 0 deletions gnome-extension/tray-position@gitify.app/metadata.json
Original file line number Diff line number Diff line change
@@ -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
}
119 changes: 119 additions & 0 deletions src/main/gnome.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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();
});
});
});
76 changes: 76 additions & 0 deletions src/main/gnome.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return run('gnome-extensions', ['info', UUID]).then(
() => true,
() => false,
);
}

function isActive(): Promise<boolean> {
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<GnomeExtensionState> {
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<GnomeExtensionState> {
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<GnomeExtensionState> {
await run('gnome-extensions', ['enable', UUID]).catch((err) =>
logError('gnome:enableExtension', 'Unable to enable the extension', toError(err)),
);

return getExtensionState();
}
5 changes: 5 additions & 0 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),

Expand Down
6 changes: 6 additions & 0 deletions src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/components/GlobalEffects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading