From 7f1c5efbee4028f5ce4cc1b3e766e62b99ffe1ab Mon Sep 17 00:00:00 2001 From: Parthu Date: Fri, 31 Jul 2026 20:37:29 +0530 Subject: [PATCH] add menubar teardown API --- README.md | 4 +- src/Menubar.spec.ts | 68 +++++++++++++++++++++++ src/Menubar.ts | 113 ++++++++++++++++++++++++++++++++------ src/__mocks__/electron.ts | 47 ++++++++-------- 4 files changed, 193 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1b3c18ee..b6586eda 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/Menubar.spec.ts b/src/Menubar.spec.ts index 19058065..5727215e 100644 --- a/src/Menubar.spec.ts +++ b/src/Menubar.spec.ts @@ -42,4 +42,72 @@ describe('Menubar', () => { }); }); }); + + it('destroys its window, owned tray, and registered listeners', () => { + return new Promise((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((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((resolve) => { + mb!.on('ready', () => { + const tray = mb!.tray; + + mb!.destroy(); + mb!.destroy(); + + expect(tray.destroy).toHaveBeenCalledTimes(1); + expect(mb!.isDestroyed()).toBe(true); + resolve(); + }); + }); + }); + }); diff --git a/src/Menubar.ts b/src/Menubar.ts index bc0b25d0..970166be 100644 --- a/src/Menubar.ts +++ b/src/Menubar.ts @@ -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) { 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); } } @@ -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[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. * @@ -126,6 +202,9 @@ export class Menubar extends EventEmitter { * @param trayPos - The bounds to show the window in. */ async showWindow(trayPos?: Electron.Rectangle): Promise { + if (this._isDestroyed) { + return; + } if (!this.tray) { throw new Error('Tray should have been instantiated by now'); } @@ -195,16 +274,15 @@ export class Menubar extends EventEmitter { } private async appReady(): Promise { + 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 = @@ -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'; @@ -223,10 +301,10 @@ export class Menubar extends EventEmitter { throw new Error('Tray has been initialized above'); } this.tray.on( - defaultClickEvent as Parameters[0], - this.clicked.bind(this), + this._defaultClickEvent as Parameters[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) { @@ -237,6 +315,9 @@ export class Menubar extends EventEmitter { await this.createWindow(); } + if (this._isDestroyed) { + return; + } this.emit('ready'); } @@ -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'); diff --git a/src/__mocks__/electron.ts b/src/__mocks__/electron.ts index 0e921202..e405433e 100644 --- a/src/__mocks__/electron.ts +++ b/src/__mocks__/electron.ts @@ -4,32 +4,35 @@ export const MOCK_APP_GETAPPPATH = 'mock.app.getAppPath'; export const app = { getAppPath: jest.fn(() => MOCK_APP_GETAPPPATH), - isReady: (): Promise => 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(); }