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
9 changes: 7 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
onFirstRunMaybe,
} from './lifecycle';
import MenuBuilder from './menu';
import { detectPackageManager } from './packageManager';
import AppUpdater from './updater';
import { isDevMode } from './utils';

Expand All @@ -36,14 +37,18 @@ const mb = menubar({
escapeToHide: true, // Hide the window when Escape is pressed.
});

const menuBuilder = new MenuBuilder(mb);
// A package manager that installed the app also owns its updates, which shapes
// both the update menu items and whether the updater runs at all
const packageManager = detectPackageManager();

const menuBuilder = new MenuBuilder(mb, packageManager);
const contextMenu = menuBuilder.buildMenu();

// Register your app as the handler for a custom protocol
const protocol = isDevMode() ? 'gitify-dev' : 'gitify';
app.setAsDefaultProtocolClient(protocol);

const appUpdater = new AppUpdater(mb, menuBuilder);
const appUpdater = new AppUpdater(mb, menuBuilder, packageManager);

app.whenReady().then(async () => {
await onFirstRunMaybe();
Expand Down
48 changes: 41 additions & 7 deletions src/main/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('main/menu.ts', () => {
setContextMenu: vi.fn(),
},
} as unknown as Menubar;
menuBuilder = new MenuBuilder(menubar);
menuBuilder = new MenuBuilder(menubar, null);
});

describe('checkForUpdatesMenuItem', () => {
Expand Down Expand Up @@ -210,6 +210,34 @@ describe('main/menu.ts', () => {
});
});

describe('package managed updates', () => {
beforeEach(() => {
menuItemInstances.length = 0;
menuBuilder = new MenuBuilder(menubar, 'Homebrew');
});

it('names the package manager that owns updates', () => {
menuBuilder.buildMenu();

const config = getMenuItemConfigByLabel('Updates are managed by Homebrew');

expect(config).toBeDefined();
expect(config?.enabled).toBe(false);
expect(config?.click).toBeUndefined();
});

it('replaces the update menu items with the notice', () => {
const template = buildAndGetTemplate();
const labels = template.map((item) => item?.label);

expect(labels).toContain('Updates are managed by Homebrew');
expect(labels).not.toContain('Check for updates');
expect(labels).not.toContain('No updates available');
expect(labels).not.toContain('An update is available');
expect(labels).not.toContain('Restart to install update');
});
});

describe('windowVisibilityMenuItems', () => {
it('show item is visible by default; hide item is not', () => {
const showCfg = getMenuItemConfigByLabel(`Show ${APPLICATION.NAME}`);
Expand Down Expand Up @@ -334,9 +362,12 @@ describe('main/menu.ts', () => {
menuItemInstances.length = 0;
(Menu.buildFromTemplate as Mock).mockClear();

const mb = new MenuBuilder({
app: { quit: vi.fn() },
} as unknown as Menubar);
const mb = new MenuBuilder(
{
app: { quit: vi.fn() },
} as unknown as Menubar,
null,
);
mb.buildMenu();

const template = (Menu.buildFromTemplate as Mock).mock.calls.slice(
Expand All @@ -353,9 +384,12 @@ describe('main/menu.ts', () => {
menuItemInstances.length = 0;
(Menu.buildFromTemplate as Mock).mockClear();

const mb = new MenuBuilder({
app: { quit: vi.fn() },
} as unknown as Menubar);
const mb = new MenuBuilder(
{
app: { quit: vi.fn() },
} as unknown as Menubar,
null,
);
mb.buildMenu();

const template = (Menu.buildFromTemplate as Mock).mock.calls.slice(
Expand Down
35 changes: 30 additions & 5 deletions src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { APPLICATION } from '../shared/constants';
import { isMacOS } from '../shared/platform';

import { resetApp } from './lifecycle/reset';
import type { PackageManager } from './packageManager';
import { openLogsDirectory, takeScreenshot } from './utils';

/**
Expand All @@ -20,13 +21,16 @@ export default class MenuBuilder {
private readonly hideWindowMenuItem: MenuItem;

private readonly menubar: Menubar;
private readonly packageManager: PackageManager | null;
private menu?: Menu;

/**
* @param menubar - The menubar instance used for window and app interactions within menu actions.
* @param packageManager - The package manager that owns this install, or `null` when the app was installed manually.
*/
constructor(menubar: Menubar) {
constructor(menubar: Menubar, packageManager: PackageManager | null) {
this.menubar = menubar;
this.packageManager = packageManager;

this.checkForUpdatesMenuItem = new MenuItem({
label: 'Check for updates',
Expand Down Expand Up @@ -82,10 +86,7 @@ export default class MenuBuilder {
this.showWindowMenuItem,
this.hideWindowMenuItem,
{ type: 'separator' },
this.checkForUpdatesMenuItem,
this.noUpdateAvailableMenuItem,
this.updateAvailableMenuItem,
this.updateReadyForInstallMenuItem,
...this.buildUpdateMenuItems(),
{ type: 'separator' },
{
label: 'Developer',
Expand Down Expand Up @@ -140,6 +141,30 @@ export default class MenuBuilder {
return this.menu;
}

/**
* Build the update section of the menu.
*
* A package managed install never checks for updates, so it gets a note naming
* the package manager to update through instead of controls that do nothing.
*/
private buildUpdateMenuItems(): MenuItem[] {
if (this.packageManager) {
return [
new MenuItem({
label: `Updates are managed by ${this.packageManager}`,
enabled: false,
}),
];
}

return [
this.checkForUpdatesMenuItem,
this.noUpdateAvailableMenuItem,
this.updateAvailableMenuItem,
this.updateReadyForInstallMenuItem,
];
}

/**
* Reflect the current window visibility in the Show / Hide menu items.
* `electron-menubar` re-publishes the menu to the SNI host on every
Expand Down
120 changes: 120 additions & 0 deletions src/main/packageManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { isMacOS } from '../shared/platform';

const APP_BUNDLE_PATH = '/Applications/Gitify.app';
const APP_EXE_PATH = `${APP_BUNDLE_PATH}/Contents/MacOS/Gitify`;

/** Fake file system: directory listings and the paths that symlinks resolve to */
const fileSystem: {
directories: Record<string, string[]>;
resolvedPaths: Record<string, string>;
} = {
directories: {},
resolvedPaths: {},
};

const readdirSync = (directoryPath: string) => {
const entries = fileSystem.directories[directoryPath];

if (!entries) {
throw new Error(`ENOENT: no such directory, scandir '${directoryPath}'`);
}

return entries;
};

const realpathSync = (targetPath: string) => {
const resolvedPath = fileSystem.resolvedPaths[targetPath];

if (!resolvedPath) {
throw new Error(`ENOENT: no such file or directory, lstat '${targetPath}'`);
}

return resolvedPath;
};

vi.mock('node:fs', () => ({
default: {
readdirSync: (directoryPath: string) => readdirSync(directoryPath),
realpathSync: (targetPath: string) => realpathSync(targetPath),
},
}));

vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => APP_EXE_PATH),
} satisfies Pick<Electron.App, 'getPath'>,
}));

vi.mock('../shared/platform', () => ({
isMacOS: vi.fn(),
}));

import { detectPackageManager } from './packageManager';

describe('main/packageManager.ts', () => {
/** Install the cask metadata that a `brew install --cask gitify` leaves behind */
const givenHomebrewCaskInstall = (prefix: string, bundlePath = APP_BUNDLE_PATH) => {
fileSystem.directories[`${prefix}/Caskroom/gitify`] = ['.metadata', '7.4.0'];
fileSystem.resolvedPaths[`${prefix}/Caskroom/gitify/7.4.0/Gitify.app`] = bundlePath;
};

beforeEach(() => {
fileSystem.directories = {};
fileSystem.resolvedPaths = { [APP_BUNDLE_PATH]: APP_BUNDLE_PATH };

vi.stubEnv('HOMEBREW_PREFIX', '');
vi.mocked(isMacOS).mockReturnValue(true);
});

afterEach(() => {
vi.unstubAllEnvs();
});

describe('detectPackageManager', () => {
it('returns null for a manual install', () => {
expect(detectPackageManager()).toBeNull();
});

it.each(['/opt/homebrew', '/usr/local'])(
'detects a Homebrew cask install under %s',
(prefix) => {
givenHomebrewCaskInstall(prefix);

expect(detectPackageManager()).toBe('Homebrew');
},
);

it('detects a Homebrew cask install under a custom prefix', () => {
vi.stubEnv('HOMEBREW_PREFIX', '/custom/homebrew');
givenHomebrewCaskInstall('/custom/homebrew');

expect(detectPackageManager()).toBe('Homebrew');
});

it('returns null when the cask owns a different copy of the app', () => {
givenHomebrewCaskInstall('/opt/homebrew', '/Users/test/Applications/Gitify.app');

expect(detectPackageManager()).toBeNull();
});

it('returns null when the cask is no longer installed', () => {
fileSystem.directories['/opt/homebrew/Caskroom/gitify'] = ['.metadata', '7.4.0'];

expect(detectPackageManager()).toBeNull();
});

it('returns null when the app bundle cannot be resolved', () => {
fileSystem.resolvedPaths = {};
givenHomebrewCaskInstall('/opt/homebrew');

expect(detectPackageManager()).toBeNull();
});

it('skips Homebrew detection when not running on macOS', () => {
vi.mocked(isMacOS).mockReturnValue(false);
givenHomebrewCaskInstall('/opt/homebrew');

expect(detectPackageManager()).toBeNull();
});
});
});
Loading