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
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 window, tray, timers, and listeners,
- `isDestroyed()`: check whether the menubar instance has been destroyed

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

Expand Down
68 changes: 68 additions & 0 deletions src/Menubar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,72 @@ describe('Menubar', () => {
});
});
});

it('destroys its window, owned tray, and registered listeners', () => {
return new Promise<void>((resolve) => {
mb!.on('ready', () => {
const tray = mb!.tray;
const window = mb!.window!;

mb!.destroy();

expect(window.on).toHaveBeenCalledWith(
'closed',
expect.any(Function),
);
expect(window.destroy).toHaveBeenCalledTimes(1);
expect(tray.removeListener).toHaveBeenCalledWith(
'click',
expect.any(Function),
);
expect(tray.removeListener).toHaveBeenCalledWith(
'double-click',
expect.any(Function),
);
expect(tray.destroy).toHaveBeenCalledTimes(1);
expect(app.removeListener).toHaveBeenCalledWith(
'activate',
expect.any(Function),
);
expect(mb!.window).toBeUndefined();
expect(() => mb!.tray).toThrow();
expect(mb!.isDestroyed()).toBe(true);
resolve();
});
});
});

it('does not destroy a tray supplied by the caller', () => {
const tray = new Tray('');
const externalTrayMenubar = new Menubar(app, {
preloadWindow: true,
tray,
});

return new Promise<void>((resolve) => {
externalTrayMenubar.on('ready', () => {
externalTrayMenubar.destroy();

expect(tray.removeListener).toHaveBeenCalled();
expect(tray.destroy).not.toHaveBeenCalled();
resolve();
});
});
});

it('can be destroyed more than once', () => {
return new Promise<void>((resolve) => {
mb!.on('ready', () => {
const tray = mb!.tray;

mb!.destroy();
mb!.destroy();

expect(tray.destroy).toHaveBeenCalledTimes(1);
expect(mb!.isDestroyed()).toBe(true);
resolve();
});
});
});

});
113 changes: 97 additions & 16 deletions src/Menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,51 @@ export class Menubar extends EventEmitter {
private _app: Electron.App;
private _browserWindow?: BrowserWindow;
private _blurTimeout: NodeJS.Timeout | null = null; // track blur events with timeout
private _defaultClickEvent?: 'click' | 'right-click';
private _isDestroyed: boolean;
private _isVisible: boolean; // track visibility
private _cachedBounds?: Electron.Rectangle; // _cachedBounds are needed for double-clicked event
private _options: Options;
private _positioner: Positioner | undefined;
private _tray?: Tray;

private readonly _appReadyHandler = (): void => {
if (this._isDestroyed) {
return;
}
this.appReady().catch((err) => console.error('menubar: ', err));
};

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

private readonly _trayClickHandler = (
event?: Electron.KeyboardEvent,
bounds?: Electron.Rectangle,
): void => {
if (!this._isDestroyed) {
this.clicked(event, bounds).catch(console.error);
}
};

constructor(app: Electron.App, options?: Partial<Options>) {
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._appReadyHandler);
} else {
app.on('ready', () => {
this.appReady().catch((err) => console.error('menubar: ', err));
});
app.on('ready', this._appReadyHandler);
}
}

Expand Down Expand Up @@ -84,6 +108,58 @@ export class Menubar extends EventEmitter {
return this._browserWindow;
}

/**
* Tear down the menubar instance and release the resources it owns.
*
* Calling this method more than once has no effect. A tray supplied through
* {@link Options.tray} is detached but not destroyed.
*/
destroy(): void {
if (this._isDestroyed) {
return;
}
this._isDestroyed = true;

if (this._blurTimeout) {
clearTimeout(this._blurTimeout);
this._blurTimeout = null;
}

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

if (this._tray) {
if (this._defaultClickEvent) {
this._tray.removeListener(
this._defaultClickEvent as Parameters<Tray['on']>[0],
this._trayClickHandler,
);
}
this._tray.removeListener('double-click', this._trayClickHandler);
if (!this._options.tray && !this._tray.isDestroyed()) {
this._tray.destroy();
}
this._tray = undefined;
}

this._app.removeListener('ready', this._appReadyHandler);
this._app.removeListener('activate', this._appActivateHandler);
this._cachedBounds = undefined;
this._isVisible = false;
this._positioner = undefined;
}

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

/**
* Retrieve a menubar option.
*
Expand Down Expand Up @@ -126,6 +202,9 @@ export class Menubar extends EventEmitter {
* @param trayPos - The bounds to show the window in.
*/
async showWindow(trayPos?: Electron.Rectangle): Promise<void> {
if (this._isDestroyed) {
return;
}
if (!this.tray) {
throw new Error('Tray should have been instantiated by now');
}
Expand Down Expand Up @@ -195,16 +274,15 @@ export class Menubar extends EventEmitter {
}

private async appReady(): Promise<void> {
if (this._isDestroyed) {
return;
}
if (this.app.dock && !this._options.showDockIcon) {
this.app.dock.hide();
}

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

let trayImage =
Expand All @@ -213,7 +291,7 @@ export class Menubar extends EventEmitter {
trayImage = path.join(__dirname, '..', 'assets', 'IconTemplate.png'); // Default cat icon
}

const defaultClickEvent = this._options.showOnRightClick
this._defaultClickEvent = this._options.showOnRightClick
? 'right-click'
: 'click';

Expand All @@ -223,10 +301,10 @@ export class Menubar extends EventEmitter {
throw new Error('Tray has been initialized above');
}
this.tray.on(
defaultClickEvent as Parameters<Tray['on']>[0],
this.clicked.bind(this),
this._defaultClickEvent as Parameters<Tray['on']>[0],
this._trayClickHandler,
);
this.tray.on('double-click', this.clicked.bind(this));
this.tray.on('double-click', this._trayClickHandler);
this.tray.setToolTip(this._options.tooltip);

if (!this._options.windowPosition) {
Expand All @@ -237,6 +315,9 @@ export class Menubar extends EventEmitter {
await this.createWindow();
}

if (this._isDestroyed) {
return;
}
this.emit('ready');
}

Expand Down Expand Up @@ -304,7 +385,7 @@ export class Menubar extends EventEmitter {
});
}

this._browserWindow.on('close', this.windowClear.bind(this));
this._browserWindow.on('closed', this.windowClear.bind(this));

this.emit('before-load');

Expand Down
47 changes: 25 additions & 22 deletions src/__mocks__/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,35 @@ export const MOCK_APP_GETAPPPATH = 'mock.app.getAppPath';

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

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

on(): void {
// Do nothing
}

setVisibleOnAllWorkspaces(): void {
// Do nothing
}
destroy = jest.fn();
hide = jest.fn();
isAlwaysOnTop = jest.fn(() => false);
isDestroyed = jest.fn(() => false);
loadURL = jest.fn(() => Promise.resolve());
on = jest.fn();
setPosition = jest.fn();
setVisibleOnAllWorkspaces = jest.fn();
show = jest.fn();
}

export class Tray {
on(): void {
// Do nothing
}
export const screen = {
getDisplayMatching: jest.fn(() => ({
bounds: { height: 1080, width: 1920, x: 0, y: 0 },
workArea: { height: 1040, width: 1920, x: 0, y: 0 },
})),
};

setToolTip(): void {
// Do nothing
}
export class Tray {
destroy = jest.fn();
getBounds = jest.fn(() => ({ height: 24, width: 24, x: 0, y: 0 }));
isDestroyed = jest.fn(() => false);
on = jest.fn();
removeListener = jest.fn();
setToolTip = jest.fn();
}