Repository: manusa/electronim
Pull request #191 (2022-11-07 16:36:19)

✨ infrastructure for global keyboard shortcuts


Patch:
diff --git a/src/main/index.js b/src/main/index.js
index 9672f8b..c975321 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -13,7 +13,8 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-const {BrowserWindow, Notification, app, desktopCapturer, ipcMain: ipc, nativeTheme} = require('electron');
+const {BrowserWindow, Notification, app, desktopCapturer, ipcMain: eventBus, nativeTheme} = require('electron');
+const {registerGlobalShortcuts} = require('./keyboard-shortcuts');
 const {APP_EVENTS} = require('../constants');
 const {newAppMenu, isNotAppMenu} = require('../app-menu');
 const {TABS_CONTAINER_HEIGHT, newTabContainer, isNotTabContainer} = require('../chrome-tabs');
@@ -121,7 +122,7 @@ const handleTabReorder = (_event, {tabIds: visibleTabIds}) => {
 };
 
 const initTabListener = () => {
-  ipc.on(APP_EVENTS.tabsReady, event => {
+  eventBus.on(APP_EVENTS.tabsReady, event => {
     const currentSettings = loadSettings();
     const tabs = currentSettings.tabs
       .filter(tab => !tab.disabled)
@@ -133,27 +134,27 @@ const initTabListener = () => {
       openSettingsDialog(mainWindow)();
     }
   });
-  ipc.on(APP_EVENTS.activateTab, (_event, data) => activateTab(data.id));
-  ipc.on(APP_EVENTS.canNotify, (event, tabId) => {
+  eventBus.on(APP_EVENTS.activateTab, (_event, data) => activateTab(data.id));
+  eventBus.on(APP_EVENTS.canNotify, (event, tabId) => {
     event.returnValue = tabManager.canNotify(tabId);
   });
-  ipc.on(APP_EVENTS.notificationClick, (_event, {tabId}) => {
+  eventBus.on(APP_EVENTS.notificationClick, (_event, {tabId}) => {
     tabContainer.webContents.send(APP_EVENTS.activateTabInContainer, {tabId});
     mainWindow.restore();
     mainWindow.show();
     activateTab(tabId);
   });
-  ipc.on(APP_EVENTS.reload, handleTabReload);
-  ipc.on(APP_EVENTS.tabReorder, handleTabReorder);
-  ipc.on(APP_EVENTS.tabTraverseNext, handleTabTraverse(tabManager.getNextTab));
-  ipc.on(APP_EVENTS.tabTraversePrevious, handleTabTraverse(tabManager.getPreviousTab));
-  ipc.on(APP_EVENTS.zoomIn, handleZoomIn);
-  ipc.on(APP_EVENTS.zoomOut, handleZoomOut);
-  ipc.on(APP_EVENTS.zoomReset, handleZoomReset);
+  eventBus.on(APP_EVENTS.reload, handleTabReload);
+  eventBus.on(APP_EVENTS.tabReorder, handleTabReorder);
+  eventBus.on(APP_EVENTS.tabTraverseNext, handleTabTraverse(tabManager.getNextTab));
+  eventBus.on(APP_EVENTS.tabTraversePrevious, handleTabTraverse(tabManager.getPreviousTab));
+  eventBus.on(APP_EVENTS.zoomIn, handleZoomIn);
+  eventBus.on(APP_EVENTS.zoomOut, handleZoomOut);
+  eventBus.on(APP_EVENTS.zoomReset, handleZoomReset);
 };
 
 const initDesktopCapturerHandler = () => {
-  ipc.handle(APP_EVENTS.desktopCapturerGetSources, (_event, opts) => desktopCapturer.getSources(opts));
+  eventBus.handle(APP_EVENTS.desktopCapturerGetSources, (_event, opts) => desktopCapturer.getSources(opts));
 };
 
 const appMenuOpen = () => {
@@ -168,6 +169,9 @@ const appMenuClose = () => {
 };
 
 const closeDialog = () => {
+  if (mainWindow.getBrowserViews().length > 1) {
+    return;
+  }
   const dialogView = mainWindow.getBrowserView();
   activateTab(tabManager.getActiveTab());
   dialogView.webContents.destroy();
@@ -186,16 +190,16 @@ const saveSettings = (_event, settings) => {
 };
 
 const initGlobalListeners = () => {
-  ipc.on(APP_EVENTS.appMenuOpen, appMenuOpen);
-  ipc.on(APP_EVENTS.appMenuClose, appMenuClose);
-  ipc.on(APP_EVENTS.closeDialog, closeDialog);
-  ipc.handle(APP_EVENTS.dictionaryGetAvailable, getAvailableDictionaries);
-  ipc.handle(APP_EVENTS.dictionaryGetAvailableNative, getAvailableNativeDictionaries);
-  ipc.handle(APP_EVENTS.dictionaryGetEnabled, getEnabledDictionaries);
-  ipc.on(APP_EVENTS.helpOpenDialog, openHelpDialog(mainWindow));
-  ipc.handle(APP_EVENTS.settingsLoad, loadSettings);
-  ipc.on(APP_EVENTS.settingsOpenDialog, openSettingsDialog(mainWindow));
-  ipc.on(APP_EVENTS.settingsSave, saveSettings);
+  eventBus.on(APP_EVENTS.appMenuOpen, appMenuOpen);
+  eventBus.on(APP_EVENTS.appMenuClose, appMenuClose);
+  eventBus.on(APP_EVENTS.closeDialog, closeDialog);
+  eventBus.handle(APP_EVENTS.dictionaryGetAvailable, getAvailableDictionaries);
+  eventBus.handle(APP_EVENTS.dictionaryGetAvailableNative, getAvailableNativeDictionaries);
+  eventBus.handle(APP_EVENTS.dictionaryGetEnabled, getEnabledDictionaries);
+  eventBus.on(APP_EVENTS.helpOpenDialog, openHelpDialog(mainWindow));
+  eventBus.handle(APP_EVENTS.settingsLoad, loadSettings);
+  eventBus.on(APP_EVENTS.settingsOpenDialog, openSettingsDialog(mainWindow));
+  eventBus.on(APP_EVENTS.settingsSave, saveSettings);
 };
 
 const browserVersionsReady = () => {
@@ -205,6 +209,7 @@ const browserVersionsReady = () => {
 };
 
 const init = () => {
+  registerGlobalShortcuts();
   fixUserDataLocation();
   loadDictionaries();
   const {width = 800, height = 600, theme} = loadSettings();
diff --git a/src/main/keyboard-shortcuts.js b/src/main/keyboard-shortcuts.js
new file mode 100644
index 0000000..b1a8bc9
--- /dev/null
+++ b/src/main/keyboard-shortcuts.js
@@ -0,0 +1,26 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+const {globalShortcut, ipcMain: eventBus} = require('electron');
+const {APP_EVENTS} = require('../constants');
+
+const registerGlobalShortcuts = () => {
+  globalShortcut.register('Escape', () => {
+    eventBus.emit(APP_EVENTS.appMenuClose);
+    eventBus.emit(APP_EVENTS.closeDialog);
+  });
+};
+
+module.exports = {registerGlobalShortcuts};


Test patch:
diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js
index 3436b55..8cf05e1 100644
--- a/src/__tests__/electron.js
+++ b/src/__tests__/electron.js
@@ -52,7 +52,7 @@ const mockBrowserWindowInstance = () => {
 const mockElectronInstance = ({...overriddenProps} = {}) => {
   const browserViewInstance = mockBrowserWindowInstance();
   const browserWindowInstance = mockBrowserWindowInstance();
-  return {
+  const instance = {
     BrowserView: jest.fn(() => browserViewInstance),
     browserViewInstance,
     BrowserWindow: jest.fn(() => browserWindowInstance),
@@ -63,6 +63,26 @@ const mockElectronInstance = ({...overriddenProps} = {}) => {
       getPath: jest.fn(),
       setPath: jest.fn()
     },
+    globalShortcut: {
+      listeners: {},
+      register: jest.fn((accelerator, callback) => {
+        instance.globalShortcut.listeners[accelerator] = callback;
+      })
+    },
+    ipcMain: {
+      listeners: {},
+      emit: jest.fn(),
+      handle: jest.fn((eventName, func) => {
+        instance.ipcMain.listeners[eventName] = func;
+      }),
+      on: jest.fn((eventName, func) => {
+        instance.ipcMain.listeners[eventName] = func;
+      }),
+      removeHandler: jest.fn(eventName => {
+        delete instance.ipcMain.listeners[eventName];
+      })
+    },
+    nativeTheme: {},
     session: {
       fromPartition: jest.fn(() => ({
         userAgentInterceptor: true
@@ -71,6 +91,7 @@ const mockElectronInstance = ({...overriddenProps} = {}) => {
     },
     ...overriddenProps
   };
+  return instance;
 };
 
 module.exports = {mockBrowserWindowInstance, mockElectronInstance};
diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js
index 6b47038..5513026 100644
--- a/src/main/__tests__/index.test.js
+++ b/src/main/__tests__/index.test.js
@@ -36,24 +36,17 @@ describe('Main module test suite', () => {
     jest.useFakeTimers({doNotFake: ['setInterval']});
     mockNotification = jest.fn();
     mockDesktopCapturer = {};
-    mockIpc = {
-      handle: jest.fn(),
-      listeners: {},
-      on: jest.fn((eventName, func) => {
-        mockIpc.listeners[eventName] = func;
-      })
-    };
     mockNativeTheme = {};
     mockSettings = {};
     jest.mock('electron', () => require('../../__tests__').mockElectronInstance({
       Notification: jest.fn(() => mockNotification),
       desktopCapturer: mockDesktopCapturer,
-      ipcMain: mockIpc,
       nativeTheme: mockNativeTheme
     }));
     electron = require('electron');
     mockBrowserView = electron.browserViewInstance;
     mockBrowserWindow = electron.browserWindowInstance;
+    mockIpc = electron.ipcMain;
     appMenuModule = require('../../app-menu');
     jest.spyOn(appMenuModule, 'newAppMenu');
     settingsModule = require('../../settings');
@@ -455,6 +448,7 @@ describe('Main module test suite', () => {
     beforeEach(() => {
       settingsView = {webContents: {destroy: jest.fn()}};
       mockBrowserWindow.getBrowserView = jest.fn(() => settingsView);
+      mockBrowserWindow.getBrowserViews = jest.fn(() => [settingsView]);
     });
     describe('saveSettings', () => {
       beforeEach(() => {
@@ -488,16 +482,28 @@ describe('Main module test suite', () => {
         expect(mockNativeTheme.themeSource).toEqual('system');
       });
     });
-    test('closeDialog, should destroy dialog view and activate current tab', () => {
-      // Given
-      jest.spyOn(tabManagerModule, 'getActiveTab').mockImplementation();
-      main.init();
-      // When
-      mockIpc.listeners.closeDialog();
-      // Then
-      expect(settingsModule.updateSettings).not.toHaveBeenCalled();
-      expect(tabManagerModule.getActiveTab).toHaveBeenCalledTimes(1);
-      expect(settingsView.webContents.destroy).toHaveBeenCalledTimes(1);
+    describe('closeDialog', () => {
+      beforeEach(() => {
+        jest.spyOn(tabManagerModule, 'getActiveTab').mockImplementation();
+        main.init();
+      });
+      test('should destroy dialog view and activate current tab', () => {
+        // When
+        mockIpc.listeners.closeDialog();
+        // Then
+        expect(settingsModule.updateSettings).not.toHaveBeenCalled();
+        expect(tabManagerModule.getActiveTab).toHaveBeenCalledTimes(1);
+        expect(settingsView.webContents.destroy).toHaveBeenCalledTimes(1);
+      });
+      test('should return if no dialog is shown (>1 view)', () => {
+        // Given
+        mockBrowserWindow.getBrowserViews = jest.fn(() => [{}, {}]);
+        // When
+        mockIpc.listeners.closeDialog();
+        // Then
+        expect(settingsModule.updateSettings).not.toHaveBeenCalled();
+        expect(tabManagerModule.getActiveTab).not.toHaveBeenCalled();
+      });
     });
     test('appMenuOpen, should show and resize app-menu', () => {
       // Given
diff --git a/src/main/__tests__/keyboard-shortcuts.test.js b/src/main/__tests__/keyboard-shortcuts.test.js
new file mode 100644
index 0000000..83a9f67
--- /dev/null
+++ b/src/main/__tests__/keyboard-shortcuts.test.js
@@ -0,0 +1,31 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+describe('Global Keyboard Shortcuts module test suite', () => {
+  let electron;
+  beforeEach(() => {
+    jest.resetModules();
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
+    require('../').init();
+    electron = require('electron');
+  });
+  describe('Escape', () => {
+    test.each(['appMenuClose', 'closeDialog'])('callback triggers %s', appEvent => {
+      // eslint-disable-next-line new-cap
+      electron.globalShortcut.listeners.Escape();
+      expect(electron.ipcMain.emit).toHaveBeenCalledWith(appEvent);
+    });
+  });
+});