Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ The return value of `menubar()` is a `Menubar` class instance, which has these p
- `setOption(option, value)`: change an option after menubar is created,
- `getOption(option)`: get an menubar option,
- `showWindow()`: show the menubar window,
- `hideWindow()`: hide the menubar window
- `hideWindow()`: hide the menubar window,
- `destroy()`: tear down the menubar instance,
- `isDestroyed()`: whether the menubar is currently destroyed.

See the reference [API docs](./docs/classes/_menubar_.menubar.md).

Expand Down
57 changes: 56 additions & 1 deletion src/Menubar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app, BrowserWindow, Tray } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';

import { Menubar } from './Menubar';

Expand All @@ -9,6 +9,7 @@ describe('Menubar', () => {
let mb: Menubar | undefined;

beforeEach(() => {
vi.clearAllMocks();
mb = new Menubar(app, { preloadWindow: true });
});

Expand Down Expand Up @@ -45,4 +46,58 @@ describe('Menubar', () => {
});
});
});

it('is not destroyed by default', () => {
expect(mb!.isDestroyed()).toBe(false);
});

it('reports as destroyed after `destroy()` is called', () => {
return new Promise<void>((resolve) => {
mb!.on('ready', () => {
mb!.destroy();
expect(mb!.isDestroyed()).toBe(true);
expect(mb!.window).toBeUndefined();
resolve();
});
});
});

it('removes tray and app listeners on `destroy()`', () => {
return new Promise<void>((resolve) => {
mb!.on('ready', () => {
const tray = mb!.tray;
mb!.destroy();

const trayEvents = (tray.removeListener as Mock).mock.calls.map(
([event]) => event,
);
expect(trayEvents).toEqual(
expect.arrayContaining(['click', 'right-click', 'double-click']),
);

const appEvents = (app.removeListener as Mock).mock.calls.map(
([event]) => event,
);
expect(appEvents).toEqual(
expect.arrayContaining(['ready', 'activate']),
);
resolve();
});
});
});

it('is idempotent: calling `destroy()` twice is a no-op', () => {
return new Promise<void>((resolve) => {
mb!.on('ready', () => {
mb!.destroy();
const callsAfterFirst = (app.removeListener as Mock).mock.calls.length;
mb!.destroy();
expect(mb!.isDestroyed()).toBe(true);
expect((app.removeListener as Mock).mock.calls.length).toBe(
callsAfterFirst,
);
resolve();
});
});
});
});
87 changes: 68 additions & 19 deletions src/Menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class Menubar extends EventEmitter {
private _app: Electron.App;
private _browserWindow?: BrowserWindow;
private _blurTimeout: NodeJS.Timeout | null = null; // track blur events with timeout
private _isDestroyed: boolean;
private _isVisible: boolean; // track visibility
private _cachedBounds?: Electron.Rectangle; // _cachedBounds are needed for double-clicked event
private _options: Options;
Expand All @@ -26,17 +27,14 @@ export class Menubar extends EventEmitter {
super();
this._app = app;
this._options = cleanOptions(options);
this._isDestroyed = false;
this._isVisible = false;

if (app.isReady()) {
// See https://github.com/maxogden/menubar/pull/151
process.nextTick(() =>
this.appReady().catch((err) => console.error('menubar: ', err)),
);
process.nextTick(this.onAppReady);
} else {
app.on('ready', () => {
this.appReady().catch((err) => console.error('menubar: ', err));
});
app.on('ready', this.onAppReady);
}
}

Expand Down Expand Up @@ -83,6 +81,46 @@ export class Menubar extends EventEmitter {
return this._browserWindow;
}

/**
* Tear down the menubar instance: destroy the window, remove the tray, and
* detach all listeners. Subsequent clicks on the tray will be no-ops until a
* new {@link Menubar} instance is created.
*/
destroy(): void {
if (this.isDestroyed()) {
return;
}

if (this._browserWindow) {
this._browserWindow.destroy();
this._browserWindow = undefined;
}

if (this._tray) {
// Ensure all potential listeners are removed.
for (const event of ['click', 'right-click', 'double-click']) {
this._tray.removeListener(
event as Parameters<Tray['on']>[0],
this.clicked,
);
}
this._tray.setToolTip('');
this._tray = undefined;
}

this._app.removeListener('ready', this.onAppReady);
this._app.removeListener('activate', this.onAppActivate);

this._isDestroyed = true;
}

/**
* Whether {@link destroy} has been called on this menubar instance.
*/
isDestroyed(): boolean {
return this._isDestroyed;
}

/**
* Retrieve a menubar option.
*
Expand Down Expand Up @@ -199,11 +237,7 @@ export class Menubar extends EventEmitter {
}

if (this._options.activateWithApp) {
this.app.on('activate', (_event, hasVisibleWindows) => {
if (!hasVisibleWindows) {
this.showWindow().catch(console.error);
}
});
this.app.on('activate', this.onAppActivate);
}

let trayImage =
Expand All @@ -221,11 +255,8 @@ export class Menubar extends EventEmitter {
if (!this.tray) {
throw new Error('Tray has been initialized above');
}
this.tray.on(
defaultClickEvent as Parameters<Tray['on']>[0],
this.clicked.bind(this),
);
this.tray.on('double-click', this.clicked.bind(this));
this.tray.on(defaultClickEvent as Parameters<Tray['on']>[0], this.clicked);
this.tray.on('double-click', this.clicked);
this.tray.setToolTip(this._options.tooltip);

if (!this._options.windowPosition) {
Expand All @@ -245,10 +276,10 @@ export class Menubar extends EventEmitter {
* @param e
* @param bounds
*/
private async clicked(
private clicked = async (
event?: Electron.KeyboardEvent,
bounds?: Electron.Rectangle,
): Promise<void> {
): Promise<void> => {
if (event && (event.shiftKey || event.ctrlKey || event.metaKey)) {
return this.hideWindow();
}
Expand All @@ -264,7 +295,25 @@ export class Menubar extends EventEmitter {

this._cachedBounds = bounds || this._cachedBounds;
await this.showWindow(this._cachedBounds);
}
};

private onAppActivate = (
_event: Electron.Event,
hasVisibleWindows: boolean,
): void => {
if (!hasVisibleWindows) {
this.showWindow().catch(console.error);
}
};

private onAppReady = (): void => {
// Guard against `destroy()` being called between construction and the
// scheduled `process.nextTick`/`'ready'` firing.
if (this._isDestroyed) {
return;
}
this.appReady().catch((err) => console.error('menubar: ', err));
};

private async createWindow(): Promise<void> {
this.emit('create-window');
Expand Down
39 changes: 16 additions & 23 deletions src/__mocks__/electron.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
// https://github.com/electron/electron/issues/3909#issuecomment-190990825

import { vi } from 'vitest';
import { type Mock, vi } from 'vitest';

export const MOCK_APP_GETAPPPATH = 'mock.app.getAppPath';

export const app = {
export const app: {
getAppPath: Mock;
isReady: () => Promise<void>;
on: Mock;
removeListener: Mock;
} = {
getAppPath: vi.fn(() => MOCK_APP_GETAPPPATH),
isReady: (): Promise<void> => Promise.resolve(),
on: (): void => {
/* Do nothing */
},
on: vi.fn(),
removeListener: vi.fn(),
};

export class BrowserWindow {
loadURL(): void {
// Do nothing
}

on(): void {
// Do nothing
}

setVisibleOnAllWorkspaces(): void {
// Do nothing
}
destroy: Mock = vi.fn();
loadURL: Mock = vi.fn();
on: Mock = vi.fn();
setVisibleOnAllWorkspaces: Mock = vi.fn();
}

export class Tray {
on(): void {
// Do nothing
}

setToolTip(): void {
// Do nothing
}
on: Mock = vi.fn();
removeListener: Mock = vi.fn();
setToolTip: Mock = vi.fn();
}