Repository: manusa/electronim
Pull request #228 (2022-11-27 07:29:46)

✨ initial support for Tray
Relates to #44

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=manusa_electronim&pullRequest=228)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=228&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=228&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=228&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=228&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=228&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=228&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=228&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=228&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/src/components/icon.mjs b/src/components/icon.mjs
index 85750bc..ffa0f2e 100644
--- a/src/components/icon.mjs
+++ b/src/components/icon.mjs
@@ -41,6 +41,7 @@ Icon.checkBoxOutlineBlank = '\ue835';
 Icon.delete = '\ue872';
 Icon.expandMore = '\ue5cf';
 Icon.help = '\ue887';
+Icon.inbox = '\ue156';
 Icon.info = '\ue88e';
 Icon.lock = '\ue88d';
 Icon.lockOpen = '\ue898';
diff --git a/src/constants/index.js b/src/constants/index.js
index 839315b..4e0ee22 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -53,6 +53,7 @@ const APP_EVENTS = {
   tabSwitchToPosition: 'tabSwitchToPosition',
   tabTraverseNext: 'tabTraverseNext',
   tabTraversePrevious: 'tabTraversePrevious',
+  trayInit: 'trayInit',
   zoomIn: 'zoomIn',
   zoomOut: 'zoomOut',
   zoomReset: 'zoomReset'
diff --git a/src/main/index.js b/src/main/index.js
index 27926ff..641b8b8 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -27,6 +27,7 @@ const {
   getAvailableDictionaries, getAvailableNativeDictionaries, loadDictionaries, getEnabledDictionaries
 } = require('../spell-check');
 const tabManager = require('../tab-manager');
+const {initTray} = require('../tray');
 const {initBrowserVersions, userAgentForView} = require('../user-agent');
 
 const webPreferences = {
@@ -195,13 +196,14 @@ const closeDialog = () => {
 const saveSettings = (_event, settings) => {
   updateSettings(settings);
   loadDictionaries();
-  nativeTheme.themeSource = settings.theme ?? 'system';
+  nativeTheme.themeSource = settings.theme;
   const currentBrowserView = mainWindow.getBrowserView();
   mainWindow.removeBrowserView(currentBrowserView);
   tabManager.removeAll();
   const viewsToDestroy = [currentBrowserView, tabContainer];
   viewsToDestroy.forEach(view => view.webContents.destroy());
   tabContainer = newTabContainer();
+  eventBus.emit(APP_EVENTS.trayInit);
 };
 
 const initGlobalListeners = () => {
@@ -220,19 +222,21 @@ const initGlobalListeners = () => {
   eventBus.on(APP_EVENTS.tabSwitchToPosition, handleTabSwitchToPosition);
   eventBus.on(APP_EVENTS.tabTraverseNext, handleTabTraverse(tabManager.getNextTab));
   eventBus.on(APP_EVENTS.tabTraversePrevious, handleTabTraverse(tabManager.getPreviousTab));
+  eventBus.on(APP_EVENTS.trayInit, initTray);
 };
 
 const browserVersionsReady = () => {
   app.userAgentFallback = userAgentForView(mainWindow);
   tabContainer = newTabContainer();
   appMenu = newAppMenu();
+  eventBus.emit(APP_EVENTS.trayInit);
 };
 
 const init = () => {
   fixUserDataLocation();
   loadDictionaries();
   const {width = 800, height = 600, theme} = loadSettings();
-  nativeTheme.themeSource = theme ?? 'system';
+  nativeTheme.themeSource = theme;
   mainWindow = new BrowserWindow({
     width, height, resizable: true, maximizable: true,
     icon: path.resolve(__dirname, '..', '..', 'assets', getPlatform() === 'linux' ? 'icon.png' : 'icon.ico'),
diff --git a/src/settings/index.js b/src/settings/index.js
index 0f66332..51d2bf2 100644
--- a/src/settings/index.js
+++ b/src/settings/index.js
@@ -21,7 +21,13 @@ const {showDialog} = require('../browser-window');
 
 const APP_DIR = '.electronim';
 const SETTINGS_FILE = 'settings.json';
-const DEFAULT_SETTINGS = {tabs: [], useNativeSpellChecker: false, enabledDictionaries: ['en-US']};
+const DEFAULT_SETTINGS = {
+  tabs: [],
+  useNativeSpellChecker: false,
+  enabledDictionaries: ['en-US'],
+  theme: 'system',
+  trayEnabled: false
+};
 
 const webPreferences = {
   contextIsolation: false,
@@ -44,7 +50,8 @@ const getPlatform = () => process.platform;
 
 const containsTabId = tabs => tabId => tabs.map(({id}) => id).includes(tabId);
 
-const ensureActiveTab = settings => {
+const ensureDefaultValues = settings => {
+  settings = Object.assign(DEFAULT_SETTINGS, settings);
   let {activeTab} = settings;
   const enabledTabs = settings.tabs.filter(({disabled}) => !disabled);
   if (enabledTabs.length > 0 && !containsTabId(enabledTabs)(activeTab)) {
@@ -61,7 +68,7 @@ const loadSettings = () => {
   if (fs.existsSync(settingsPath)) {
     loadedSettings = JSON.parse(fs.readFileSync(settingsPath));
   }
-  return ensureActiveTab(Object.assign(DEFAULT_SETTINGS, loadedSettings));
+  return ensureDefaultValues(loadedSettings);
 };
 
 const writeSettings = settings => {
@@ -70,7 +77,7 @@ const writeSettings = settings => {
 };
 
 const updateSettings = settings =>
-  writeSettings(ensureActiveTab({...loadSettings(), ...settings}));
+  writeSettings(ensureDefaultValues({...loadSettings(), ...settings}));
 
 const openSettingsDialog = mainWindow => () => {
   const settingsView = new BrowserView({webPreferences});
diff --git a/src/settings/settings.browser.mjs b/src/settings/settings.browser.mjs
index 5609776..434246b 100644
--- a/src/settings/settings.browser.mjs
+++ b/src/settings/settings.browser.mjs
@@ -42,7 +42,8 @@ const Settings = ({initialState}) => {
     useNativeSpellChecker: state.useNativeSpellChecker,
     enabledDictionaries,
     disableNotificationsGlobally: state.disableNotificationsGlobally,
-    theme: state.theme
+    theme: state.theme,
+    trayEnabled: state.trayEnabled
   });
   const cancel = () => ipcRenderer.send(APP_EVENTS.closeDialog);
   return html`
@@ -91,7 +92,8 @@ Promise.all([
       newTabValid: false,
       newTabValue: '',
       disableNotificationsGlobally: currentSettings.disableNotificationsGlobally,
-      theme: currentSettings.theme
+      theme: currentSettings.theme,
+      trayEnabled: currentSettings.trayEnabled
     };
     render(html`<${Settings} initialState=${initialState} />`, settingsRoot());
   }
diff --git a/src/settings/settings.other.browser.mjs b/src/settings/settings.other.browser.mjs
index eb2e5df..7ded4b3 100644
--- a/src/settings/settings.other.browser.mjs
+++ b/src/settings/settings.other.browser.mjs
@@ -17,42 +17,45 @@ import {ELECTRONIM_VERSION, html, Card, Icon, Select} from '../components/index.
 import {
   isPaneActive,
   setTheme,
-  toggleNotifications
+  toggleNotifications,
+  toggleTray
 } from './settings.reducer.browser.mjs';
 import {SettingsOption} from './settings.common.browser.mjs';
 
-export const OtherPane = ({dispatch, state}) => {
-  const onToggleGlobalNotifications = event => {
-    event.stopPropagation();
-    toggleNotifications({dispatch})();
-  };
-  return isPaneActive(state)(OtherPane.id) && html`
-    <h2 class='title'>Other</h2>
-    <${Card} className='settings__other'>
-      <div>
-        <${Select}
-          data-testid='settings-theme-select'
-          label='Theme' value=${state.theme} onChange=${e => setTheme({dispatch})(e.target.value)}
-        >
-          <${Select.Option} value='system'>system</${Select.Option}>
-          <${Select.Option} value='light'>light</${Select.Option}>
-          <${Select.Option} value='dark'>dark</${Select.Option}>
-        </${Select}>
-      </div>
-      <${Card.Divider} />
-      <${SettingsOption}
-          className='settings__global-notifications'
-          label='Disable notifications globally'
-          icon=${state.disableNotificationsGlobally ? Icon.notificationsOff : Icon.notifications}
-          checked=${state.disableNotificationsGlobally}
-          onClick=${onToggleGlobalNotifications}
-      />
-      <${Card.Divider} />
-      <div data-testid='settings-electronim-version'>
-        ElectronIM version ${ELECTRONIM_VERSION}
-      </div>
-    </${Card}>
-  `;
-};
+export const OtherPane = ({dispatch, state}) => isPaneActive(state)(OtherPane.id) && html`
+  <h2 class='title'>Other</h2>
+  <${Card} className='settings__other'>
+    <div>
+      <${Select}
+        data-testid='settings-theme-select'
+        label='Theme' value=${state.theme} onChange=${e => setTheme({dispatch})(e.target.value)}
+      >
+        <${Select.Option} value='system'>system</${Select.Option}>
+        <${Select.Option} value='light'>light</${Select.Option}>
+        <${Select.Option} value='dark'>dark</${Select.Option}>
+      </${Select}>
+    </div>
+    <${Card.Divider} />
+    <${SettingsOption}
+        className='settings__global-notifications'
+        label='Disable notifications globally'
+        icon=${state.disableNotificationsGlobally ? Icon.notificationsOff : Icon.notifications}
+        checked=${state.disableNotificationsGlobally}
+        onClick=${toggleNotifications({dispatch})}
+    />
+    <${Card.Divider} />
+    <${SettingsOption}
+        className='settings__tray'
+        label='Show ElectronIM in System Tray'
+        icon=${Icon.inbox}
+        checked=${state.trayEnabled}
+        onClick=${toggleTray({dispatch})}
+    />
+    <${Card.Divider} />
+    <div data-testid='settings-electronim-version'>
+      ElectronIM version ${ELECTRONIM_VERSION}
+    </div>
+  </${Card}>
+`;
 
 OtherPane.id = 'other';
diff --git a/src/settings/settings.reducer.browser.mjs b/src/settings/settings.reducer.browser.mjs
index 03d4763..dc824fa 100644
--- a/src/settings/settings.reducer.browser.mjs
+++ b/src/settings/settings.reducer.browser.mjs
@@ -23,9 +23,10 @@ export const ACTIONS = {
   SET_THEME: 'SET_THEME',
   TOGGLE_USE_NATIVE_SPELL_CHECKER: 'TOGGLE_USE_NATIVE_SPELL_CHECKER',
   TOGGLE_DICTIONARY: 'TOGGLE_DICTIONARY',
+  TOGGLE_GLOBAL_NOTIFICATIONS: 'TOGGLE_GLOBAL_NOTIFICATIONS',
   TOGGLE_TAB_EXPANDED: 'TOGGLE_TAB_EXPANDED',
   TOGGLE_TAB_PROPERTY: 'TOGGLE_TAB_PROPERTY',
-  TOGGLE_GLOBAL_NOTIFICATIONS: 'TOGGLE_GLOBAL_NOTIFICATIONS',
+  TOGGLE_TRAY: 'TOGGLE_TRAY',
   UPDATE_NEW_TAB_VALUE: 'UPDATE_NEW_TAB_VALUE'
 };
 
@@ -136,6 +137,11 @@ export const reducer = (state, action) => {
         disableNotificationsGlobally: !state.disableNotificationsGlobally
       };
     }
+    case ACTIONS.TOGGLE_TRAY: {
+      return {...state,
+        trayEnabled: !state.trayEnabled
+      };
+    }
     case ACTIONS.UPDATE_NEW_TAB_VALUE: {
       return {...state,
         newTabValid: validateUrl(action.payload),
@@ -156,11 +162,13 @@ export const setTabProperty = ({dispatch, property, value, id}) =>
   dispatch({type: ACTIONS.SET_TAB_PROPERTY, payload: {id, property, value}});
 export const setTheme = ({dispatch}) =>
   theme => dispatch({type: ACTIONS.SET_THEME, payload: {theme}});
-export const toggleUseNativeSpellChecker = ({dispatch}) =>
-  () => dispatch({type: ACTIONS.TOGGLE_USE_NATIVE_SPELL_CHECKER});
 export const toggleDictionary = ({dispatch, languageKey}) =>
   () => dispatch({type: ACTIONS.TOGGLE_DICTIONARY, payload: languageKey});
 export const toggleNotifications = ({dispatch}) =>
   () => dispatch({type: ACTIONS.TOGGLE_GLOBAL_NOTIFICATIONS});
 export const toggleTabProperty = (dispatch, property, id) =>
   () => dispatch({type: ACTIONS.TOGGLE_TAB_PROPERTY, payload: {id, property}});
+export const toggleTray = ({dispatch}) =>
+  () => dispatch({type: ACTIONS.TOGGLE_TRAY});
+export const toggleUseNativeSpellChecker = ({dispatch}) =>
+  () => dispatch({type: ACTIONS.TOGGLE_USE_NATIVE_SPELL_CHECKER});
diff --git a/src/tab-manager/index.js b/src/tab-manager/index.js
index 18904b3..f8f4a07 100644
--- a/src/tab-manager/index.js
+++ b/src/tab-manager/index.js
@@ -16,7 +16,7 @@
 const {app, BrowserView, session} = require('electron');
 const path = require('path');
 const {APP_EVENTS} = require('../constants');
-const settings = require('../settings');
+const {loadSettings, updateSettings} = require('../settings');
 const {getEnabledDictionaries, getUseNativeSpellChecker} = require('../spell-check');
 const {userAgentForView, addUserAgentInterceptor} = require('../user-agent');
 const {handleContextMenu} = require('./context-menu');
@@ -121,7 +121,7 @@ const getActiveTab = () => activeTab;
 
 const setActiveTab = tabId => {
   activeTab = tabId.toString();
-  settings.updateSettings({activeTab});
+  updateSettings({activeTab});
 };
 
 const getTabTraverse = operation => () => {
@@ -156,7 +156,7 @@ const removeAll = () => {
 const reload = () => Object.values(tabs).forEach(browserView => browserView.webContents.reload());
 
 const canNotify = tabId => {
-  const {tabs: tabsSettings, disableNotificationsGlobally} = settings.loadSettings();
+  const {tabs: tabsSettings, disableNotificationsGlobally} = loadSettings();
   const currentTab = tabsSettings.find(tab => tab.id === tabId);
   if (disableNotificationsGlobally === true) {
     return false;
diff --git a/src/tray/index.js b/src/tray/index.js
new file mode 100644
index 0000000..c273876
--- /dev/null
+++ b/src/tray/index.js
@@ -0,0 +1,33 @@
+/*
+   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 {Tray} = require('electron');
+const path = require('path');
+const {getPlatform, loadSettings} = require('../settings');
+let tray;
+
+const initTray = () => {
+  if (tray && tray.destroy) {
+    tray.destroy();
+    tray = null;
+  }
+  const {trayEnabled} = loadSettings();
+  if (trayEnabled) {
+    tray = new Tray(path.resolve(__dirname, '..', '..', 'assets', getPlatform() === 'linux' ? 'icon.png' : 'icon.ico'));
+  }
+};
+
+
+module.exports = {initTray};


Test patch:
diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js
index 5dd6871..8f6e2d3 100644
--- a/src/__tests__/electron.js
+++ b/src/__tests__/electron.js
@@ -66,6 +66,8 @@ const mockElectronInstance = ({...overriddenProps} = {}) => {
     browserWindowInstance,
     Menu: jest.fn(),
     MenuItem: jest.fn(),
+    Notification: jest.fn(),
+    Tray: jest.fn(),
     app: {
       getPath: jest.fn(),
       on: jest.fn(),
diff --git a/src/main/__tests__/global-listeners.test.js b/src/main/__tests__/global-listeners.test.js
index 44c27eb..9f085b7 100644
--- a/src/main/__tests__/global-listeners.test.js
+++ b/src/main/__tests__/global-listeners.test.js
@@ -16,7 +16,7 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-describe('Main :: Global listeners module test suite', () => {
+describe('Main :: Global listeners test suite', () => {
   let electron;
   let main;
   let browserWindow;
@@ -25,7 +25,9 @@ describe('Main :: Global listeners module test suite', () => {
     jest.resetModules();
     // Always mock settings unless we want to overwrite the real settings file !
     jest.mock('../../settings');
-    require('../../settings').loadSettings.mockImplementation(() => ({}));
+    require('../../settings').loadSettings.mockImplementation(() => ({
+      trayEnabled: true
+    }));
     require('../../settings').openSettingsDialog = jest.requireActual('../../settings').openSettingsDialog;
     jest.mock('electron', () => require('../../__tests__').mockElectronInstance({
       // Return a **different** instance for each view
@@ -186,12 +188,6 @@ describe('Main :: Global listeners module test suite', () => {
       // Then
       expect(electron.nativeTheme.themeSource).toEqual('light');
     });
-    test('should fallback theme to system', () => {
-      // When
-      eventBus.listeners.settingsSave({}, {});
-      // Then
-      expect(electron.nativeTheme.themeSource).toEqual('system');
-    });
   });
   describe('handleTabTraverse', () => {
     let tabManagerModule;
@@ -240,4 +236,10 @@ describe('Main :: Global listeners module test suite', () => {
       });
     });
   });
+  test('trayInit, should initialize tray', () => {
+    // When
+    eventBus.listeners.trayInit();
+    // Then
+    expect(electron.Tray).toHaveBeenCalledTimes(1);
+  });
 });
diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js
index 2424d92..5fd9d90 100644
--- a/src/main/__tests__/index.test.js
+++ b/src/main/__tests__/index.test.js
@@ -28,7 +28,6 @@ describe('Main module test suite', () => {
   let appMenuModule;
   let settingsModule;
   let tabManagerModule;
-  let userAgentModule;
   let main;
   beforeEach(() => {
     jest.resetModules();
@@ -52,13 +51,12 @@ describe('Main module test suite', () => {
     jest.spyOn(settingsModule, 'loadSettings').mockImplementation(() => mockSettings);
     jest.spyOn(settingsModule, 'updateSettings').mockImplementation();
     tabManagerModule = require('../../tab-manager');
-    jest.mock('../../user-agent');
-    userAgentModule = require('../../user-agent');
-    userAgentModule.userAgentForView.mockImplementation(() => 'UserAgent String');
-    userAgentModule.initBrowserVersions.mockImplementation(() => ({then: func => {
-      func.call();
-      return {catch: () => {}};
-    }}));
+    jest.spyOn(require('../../user-agent'), 'initBrowserVersions').mockImplementation(() => ({
+      then: func => {
+        func.call();
+        return {catch: () => {}};
+      }
+    }));
     main = require('../');
   });
   afterEach(() => {
@@ -66,21 +64,14 @@ describe('Main module test suite', () => {
   });
   describe('init - environment preparation', () => {
     describe('theme', () => {
-      test('fallbacks to system', () => {
-        // When
-        main.init();
-        jest.runAllTimers();
-        // Then
-        expect(mockNativeTheme.themeSource).toBe('system');
-      });
-      test('uses theme from saved settings', () => {
+      test.each(['dark', 'light', 'system'])('uses theme from saved settings (%s)', theme => {
         // Given
-        mockSettings.theme = 'dark';
+        mockSettings.theme = theme;
         // When
         main.init();
         jest.runAllTimers();
         // Then
-        expect(mockNativeTheme.themeSource).toBe('dark');
+        expect(mockNativeTheme.themeSource).toBe(theme);
       });
     });
     describe('icon', () => {
@@ -105,22 +96,6 @@ describe('Main module test suite', () => {
         }));
       });
     });
-    test('initBrowserVersions, successful, should be set to defaultUserAgent', () => {
-      // When
-      main.init();
-      // Then
-      expect(electron.app.userAgentFallback).toBe('UserAgent String');
-    });
-    test('initBrowserVersions, throws error, should be set to defaultUserAgent and show Notification', () => {
-      // Given
-      userAgentModule.initBrowserVersions.mockImplementation(() => ({then: () => ({catch: func => func.call()})}));
-      mockNotification.show = jest.fn();
-      // When
-      main.init();
-      // Then
-      expect(electron.app.userAgentFallback).toBe('UserAgent String');
-      expect(mockNotification.show).toHaveBeenCalledTimes(1);
-    });
     test('fixUserDataLocation, should set a location in lower-case (Electron <14 compatible)', () => {
       // Given
       electron.app.getPath.mockImplementation(() => 'ImMixed-Case/WithSome\\Separator$');
diff --git a/src/main/__tests__/init-browser-versions.test.js b/src/main/__tests__/init-browser-versions.test.js
new file mode 100644
index 0000000..fa79f3e
--- /dev/null
+++ b/src/main/__tests__/init-browser-versions.test.js
@@ -0,0 +1,76 @@
+/**
+ * @jest-environment node
+ */
+/*
+   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('Main :: initBrowserVersions test suite', () => {
+  let electron;
+  let userAgent;
+  beforeEach(() => {
+    jest.resetModules();
+    // Always mock settings unless we want to overwrite the real settings file !
+    jest.mock('../../settings');
+    require('../../settings').loadSettings.mockImplementation(() => ({trayEnabled: true}));
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
+    electron = require('electron');
+    userAgent = require('../../user-agent');
+    jest.spyOn(userAgent, 'userAgentForView').mockImplementation(() => 'UserAgent String');
+  });
+  describe('throws error', () => {
+    let show;
+    beforeEach(() => {
+      jest.spyOn(userAgent, 'initBrowserVersions')
+        .mockImplementation(() => ({then: () => ({catch: func => func.call()})}));
+      show = jest.fn();
+      electron.Notification.mockImplementation(() => ({show}));
+    });
+    test('sets default user agent', () => {
+      // When
+      require('../').init();
+      // Then
+      expect(electron.app.userAgentFallback).toBe('UserAgent String');
+    });
+    test('shows notification', () => {
+      // When
+      require('../').init();
+      // Then
+      expect(show).toHaveBeenCalledTimes(1);
+    });
+  });
+  describe('successful', () => {
+    beforeEach(() => {
+      jest.spyOn(userAgent, 'initBrowserVersions')
+        .mockImplementation(() => ({then: func => {
+          func.call();
+          return {catch: () => {}};
+        }}));
+    });
+    test('sets default user agent', () => {
+      // When
+      require('../').init();
+      // Then
+      expect(electron.app.userAgentFallback).toBe('UserAgent String');
+    });
+    test('initializes tray', () => {
+      // Given
+      electron.ipcMain.emit = event => electron.ipcMain.listeners[event]();
+      // When
+      require('../').init();
+      // Then
+      expect(electron.Tray).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/src/settings/__tests__/index.test.js b/src/settings/__tests__/index.test.js
index 358582d..fdd9ed3 100644
--- a/src/settings/__tests__/index.test.js
+++ b/src/settings/__tests__/index.test.js
@@ -42,6 +42,7 @@ describe('Settings module test suite', () => {
       expect(fs.readFileSync).not.toHaveBeenCalled();
       expect(result.tabs).toEqual([]);
       expect(result.enabledDictionaries).toEqual(['en-US']);
+      expect(result.theme).toEqual('system');
     });
     test('settings (empty) exist, should load settings from file system and merge with defaults', () => {
       // Given
@@ -55,6 +56,8 @@ describe('Settings module test suite', () => {
       expect(fs.readFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'));
       expect(result.tabs).toEqual([]);
       expect(result.enabledDictionaries).toEqual(['en-US']);
+      expect(result.theme).toEqual('system');
+      expect(result.trayEnabled).toEqual(false);
     });
     test('settings exist, should load settings from file system and merge with defaults', () => {
       // Given
@@ -81,6 +84,20 @@ describe('Settings module test suite', () => {
       expect(result.tabs).toEqual([{id: '1', disabled: true}, {id: '2'}]);
       expect(result.activeTab).toBe('2');
     });
+    test.each([
+      {key: 'theme', value: '"light"', expected: 'light'},
+      {key: 'activeTab', value: 42, expected: 42},
+      {key: 'useNativeSpellChecker', value: true, expected: true},
+      {key: 'trayEnabled', value: true, expected: true}
+    ])('settings with $key, should preserve $key', ({key, value, expected}) => {
+      // Given
+      fs.existsSync.mockImplementationOnce(() => true);
+      fs.readFileSync.mockImplementationOnce(() => `{"${key}": ${value}}`);
+      // When
+      const result = settings.loadSettings();
+      // Then
+      expect(result[key]).toBe(expected);
+    });
   });
   describe('updateSettings', () => {
     test('empty object and NO saved settings, should write default settings', () => {
@@ -91,7 +108,8 @@ describe('Settings module test suite', () => {
       // Then
       expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
       expect(fs.writeFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'),
-        '{\n  "tabs": [],\n  "useNativeSpellChecker": false,\n  "enabledDictionaries": [\n    "en-US"\n  ]\n}');
+        '{\n  "tabs": [],\n  "useNativeSpellChecker": false,\n  "enabledDictionaries": [\n    "en-US"\n  ],\n' +
+        '  "theme": "system",\n  "trayEnabled": false\n}');
     });
     test('object and saved settings, should overwrite overlapping settings', () => {
       // Given
@@ -104,6 +122,8 @@ describe('Settings module test suite', () => {
         '{\n  "tabs": [\n    {\n      "id": 1337\n    }\n  ],\n' +
         '  "useNativeSpellChecker": false,\n' +
         '  "enabledDictionaries": [\n    "en-US"\n  ],\n' +
+        '  "theme": "system",\n' +
+        '  "trayEnabled": false,\n' +
         '  "activeTab": 1337,\n  "otherSetting": "1337"\n}');
     });
     test('object and saved settings with activeTab removed, should update activeTab', () => {
@@ -117,6 +137,8 @@ describe('Settings module test suite', () => {
         '{\n  "tabs": [\n    {\n      "id": 1337\n    }\n  ],\n' +
         '  "useNativeSpellChecker": false,\n' +
         '  "enabledDictionaries": [\n    "en-US"\n  ],\n' +
+        '  "theme": "system",\n' +
+        '  "trayEnabled": false,\n' +
         '  "activeTab": 1337\n}');
     });
   });
diff --git a/src/settings/__tests__/settings.browser.mjs b/src/settings/__tests__/settings.browser.mjs
index bc57963..29d79b7 100644
--- a/src/settings/__tests__/settings.browser.mjs
+++ b/src/settings/__tests__/settings.browser.mjs
@@ -28,7 +28,8 @@ export const ipcRenderer = () => {
       {id: '1', url: 'https://initial-tab.com', sandboxed: true},
       {id: '2', url: 'https://initial-tab-2.com', disabled: true, disableNotifications: true}
     ],
-    theme: 'dark'
+    theme: 'dark',
+    trayEnabled: true
   };
   return {
     mockDictionariesAvailableNative,
diff --git a/src/settings/__tests__/settings.browser.test.mjs b/src/settings/__tests__/settings.browser.test.mjs
index e3d1401..b427375 100644
--- a/src/settings/__tests__/settings.browser.test.mjs
+++ b/src/settings/__tests__/settings.browser.test.mjs
@@ -59,7 +59,8 @@ describe('Settings in Browser test suite', () => {
           ],
           enabledDictionaries: ['en'],
           disableNotificationsGlobally: false,
-          theme: 'dark'
+          theme: 'dark',
+          trayEnabled: true
         });
     });
     test('Cancel should send close dialog event', () => {
diff --git a/src/settings/__tests__/settings.other.browser.test.mjs b/src/settings/__tests__/settings.other.browser.test.mjs
index d5ca092..97aa920 100644
--- a/src/settings/__tests__/settings.other.browser.test.mjs
+++ b/src/settings/__tests__/settings.other.browser.test.mjs
@@ -48,9 +48,10 @@ describe('Settings (Other) in Browser test suite', () => {
       }));
     }, 10000);
   });
-  describe('Toggle global notifications', () => {
+  describe('Toggle disable global notifications click', () => {
     let $notificationsSwitch;
     beforeEach(() => {
+      // In tests notifications are "Disable global notifications" is disabled by default
       $notificationsSwitch = document.querySelector('.settings__global-notifications .switch input');
     });
     test('when notifications enabled, should check input', async () => {
@@ -71,6 +72,30 @@ describe('Settings (Other) in Browser test suite', () => {
       await waitFor(() => expect($notificationsSwitch.checked).toBe(false));
     });
   });
+  describe('Toggle System Tray click', () => {
+    let $traySwitch;
+    beforeEach(() => {
+      // In tests, tray is enabled by default
+      $traySwitch = document.querySelector('.settings__tray .switch input');
+    });
+    test('when tray enabled, should uncheck input', async () => {
+      // Given
+      expect($traySwitch.checked).toBe(true);
+      // When
+      fireEvent.click($traySwitch);
+      // Then
+      await waitFor(() => expect($traySwitch.checked).toBe(false));
+    });
+    test('when tray disabled, should check input', async () => {
+      // Given
+      fireEvent.click($traySwitch);
+      await waitFor(() => expect($traySwitch.checked).toBe(false));
+      // When
+      fireEvent.click($traySwitch);
+      // Then
+      await waitFor(() => expect($traySwitch.checked).toBe(true));
+    });
+  });
   test('ElectronIM version is visible', async () => {
     const $electronimVersion = await findByTestId(document, 'settings-electronim-version');
     expect($electronimVersion.textContent).toBe('ElectronIM version 0.0.0');
diff --git a/src/tab-manager/__tests__/index.test.js b/src/tab-manager/__tests__/index.test.js
index d1fe767..66ec111 100644
--- a/src/tab-manager/__tests__/index.test.js
+++ b/src/tab-manager/__tests__/index.test.js
@@ -23,13 +23,13 @@ describe('Tab Manager module test suite', () => {
     jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
     mockBrowserView = require('electron').browserViewInstance;
     mockSettings = {
-      loadSettings: jest.fn(() => ({
-        tabs: [{id: '1337', disableNotifications: false}],
-        disableNotificationsGlobally: false
-      })),
-      updateSettings: jest.fn()
+      tabs: [{id: '1337', disableNotifications: false}],
+      disableNotificationsGlobally: false
     };
-    jest.mock('../../settings', () => mockSettings);
+    jest.mock('../../settings', () => ({
+      loadSettings: jest.fn(() => mockSettings),
+      updateSettings: jest.fn()
+    }));
     userAgent = require('../../user-agent');
     tabManager = require('../');
   });
@@ -267,10 +267,10 @@ describe('Tab Manager module test suite', () => {
     });
     test('Global notifications disabled, Notifications for this tab enabled, should return false', () => {
       // Given
-      mockSettings.loadSettings = jest.fn(() => ({
+      mockSettings = {
         tabs: [{id: '1337', disableNotifications: false}],
         disableNotificationsGlobally: true
-      }));
+      };
       // When
       const result = tabManager.canNotify('1337');
       // Then
@@ -278,10 +278,10 @@ describe('Tab Manager module test suite', () => {
     });
     test('Global notifications enabled, Notifications for this tab disabled, should return false', () => {
       // Given
-      mockSettings.loadSettings = jest.fn(() => ({
+      mockSettings = {
         tabs: [{id: '1337', disableNotifications: true}],
         disableNotificationsGlobally: false
-      }));
+      };
       // When
       const result = tabManager.canNotify('1337');
       // Then
@@ -289,9 +289,9 @@ describe('Tab Manager module test suite', () => {
     });
     test('Notifications undefined in settings, should return true (Opt-out setting)', () => {
       // Given
-      mockSettings.loadSettings = jest.fn(() => ({
+      mockSettings.loadSettings = {
         tabs: [{id: '1337'}]
-      }));
+      };
       // When
       const result = tabManager.canNotify('1337');
       // Then
diff --git a/src/tray/__tests__/index.test.js b/src/tray/__tests__/index.test.js
new file mode 100644
index 0000000..ccd35de
--- /dev/null
+++ b/src/tray/__tests__/index.test.js
@@ -0,0 +1,55 @@
+/*
+   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('Tray module test suite', () => {
+  let mockSettings;
+  let electron;
+  let tray;
+  beforeEach(() => {
+    jest.spyOn(require('../../settings'), 'loadSettings').mockImplementation(() => mockSettings);
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
+    electron = require('electron');
+    tray = require('../');
+  });
+  describe('initTray', () => {
+    test('does nothing if tray is disabled', () => {
+      // Given
+      mockSettings = {trayEnabled: false};
+      // When
+      tray.initTray();
+      // Then
+      expect(electron.Tray).toHaveBeenCalledTimes(0);
+    });
+    test('instantiates tray if tray is enabled', () => {
+      // Given
+      mockSettings = {trayEnabled: true};
+      // When
+      tray.initTray();
+      // Then
+      expect(electron.Tray).toHaveBeenCalledTimes(1);
+    });
+    test('destroy previous tray', () => {
+      // Given
+      mockSettings = {trayEnabled: true};
+      const destroy = jest.fn();
+      electron.Tray.mockImplementation(() => ({destroy}));
+      tray.initTray();
+      // When
+      tray.initTray();
+      // Then
+      expect(destroy).toHaveBeenCalledTimes(1);
+    });
+  });
+});