Repository: manusa/electronim
Pull request #232 (2022-12-01 06:27:51)

✨ app can be minimized when close button click
Part of #44 

Due to inconsistencies in the tray icon behavior across the platforms (Linux, Mac, Windows) and with the purpose of offering a smooth and exact behavior in all of them, the browser window needs to be minimized instead of hidden.

When hiding the window in Linux, there's no way to detect when the user clicks on the Tray to invoke the `BrowserWindow.show()` function. Depending on the user's X environment different things might happen.

To avoid all of this, with the current implementation, the window is just minimized which means it will remain visible in the task bar. Unless the underlying Electron's Tray implementation is changed in the future, there's nothing else that can be done if a consistent behavior across platforms wants to be preserved.

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=232)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=232&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=232&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=232&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=232&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=232&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=232&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=232&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=232&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=232&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=232&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=232&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=232&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=232&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=232&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=232&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=232&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/esm/constants.mjs b/esm/constants.mjs
index ec8a704..167122a 100644
--- a/esm/constants.mjs
+++ b/esm/constants.mjs
@@ -15,4 +15,5 @@
  */
 /* eslint-disable no-undef */
 export const APP_EVENTS = APP_EVENTS_TO_BE_REPLACED;
+export const CLOSE_BUTTON_BEHAVIORS = CLOSE_BUTTON_BEHAVIORS_TO_BE_REPLACED;
 export const ELECTRONIM_VERSION = ELECTRONIM_VERSION_TO_BE_REPLACED;
diff --git a/src/components/index.mjs b/src/components/index.mjs
index 03513cb..2c6ffeb 100644
--- a/src/components/index.mjs
+++ b/src/components/index.mjs
@@ -13,7 +13,7 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-export {APP_EVENTS, ELECTRONIM_VERSION} from '../../bundles/constants.mjs';
+export {APP_EVENTS, CLOSE_BUTTON_BEHAVIORS, ELECTRONIM_VERSION} from '../../bundles/constants.mjs';
 export {html, render, useLayoutEffect, useReducer, useState} from '../../bundles/preact.mjs';
 
 export {Card} from './card.mjs';
diff --git a/src/constants/index.js b/src/constants/index.js
index a9f874e..6067322 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -60,8 +60,13 @@ const APP_EVENTS = {
   zoomReset: 'zoomReset'
 };
 
+const CLOSE_BUTTON_BEHAVIORS = {
+  minimize: 'minimize',
+  quit: 'quit'
+};
+
 const ELECTRONIM_VERSION = JSON.parse(fs.readFileSync(path.resolve(findRootDir(), 'package.json'), 'utf8')).version;
 
 module.exports = {
-  APP_EVENTS, ELECTRONIM_VERSION
+  APP_EVENTS, CLOSE_BUTTON_BEHAVIORS, ELECTRONIM_VERSION
 };
diff --git a/src/main/index.js b/src/main/index.js
index 5b2a507..1ff53f9 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -17,7 +17,7 @@ const path = require('path');
 const {
   BrowserWindow, Notification, app, desktopCapturer, ipcMain: eventBus, nativeTheme
 } = require('electron');
-const {APP_EVENTS} = require('../constants');
+const {APP_EVENTS, CLOSE_BUTTON_BEHAVIORS} = require('../constants');
 const {openAboutDialog} = require('../about');
 const {newAppMenu, isNotAppMenu} = require('../app-menu');
 const {TABS_CONTAINER_HEIGHT, newTabContainer, isNotTabContainer} = require('../chrome-tabs');
@@ -193,6 +193,20 @@ const closeDialog = () => {
   dialogView.webContents.destroy();
 };
 
+const handleWindowClose = event => {
+  event.preventDefault();
+  const {closeButtonBehavior} = loadSettings();
+  if (closeButtonBehavior === CLOSE_BUTTON_BEHAVIORS.minimize) {
+    mainWindow.minimize();
+    // Inconsistent tray icon behaviors across platforms make it impossible to provide a consistent experience
+    // to hide the app from the task bar and just show it on the tray (mainWindow.hide()).
+    // Therefore, we minimize the window instead (always visible in the taskbar).
+    return false;
+  }
+  app.exit();
+  return true;
+};
+
 const saveSettings = (_event, settings) => {
   updateSettings(settings);
   loadDictionaries();
@@ -216,7 +230,7 @@ const initGlobalListeners = () => {
   eventBus.handle(APP_EVENTS.dictionaryGetEnabled, getEnabledDictionaries);
   eventBus.on(APP_EVENTS.fullscreenToggle, fullscreenToggle);
   eventBus.on(APP_EVENTS.helpOpenDialog, openHelpDialog);
-  eventBus.on(APP_EVENTS.quit, () => app.quit());
+  eventBus.on(APP_EVENTS.quit, app.exit);
   eventBus.handle(APP_EVENTS.settingsLoad, loadSettings);
   eventBus.on(APP_EVENTS.settingsOpenDialog, openSettingsDialog(mainWindow));
   eventBus.on(APP_EVENTS.settingsSave, saveSettings);
@@ -246,7 +260,7 @@ const init = () => {
   mainWindow.removeMenu();
   ['resize', 'maximize']
     .forEach(event => mainWindow.on(event, handleMainWindowResize));
-  mainWindow.on('closed', () => app.quit());
+  mainWindow.on('close', handleWindowClose);
   initTabListener();
   initDesktopCapturerHandler();
   initGlobalListeners();
diff --git a/src/settings/index.js b/src/settings/index.js
index 51d2bf2..57cb64f 100644
--- a/src/settings/index.js
+++ b/src/settings/index.js
@@ -17,6 +17,7 @@ const {BrowserView} = require('electron');
 const fs = require('fs');
 const path = require('path');
 const HOME_DIR = require('os').homedir();
+const {CLOSE_BUTTON_BEHAVIORS} = require('../constants');
 const {showDialog} = require('../browser-window');
 
 const APP_DIR = '.electronim';
@@ -26,7 +27,8 @@ const DEFAULT_SETTINGS = {
   useNativeSpellChecker: false,
   enabledDictionaries: ['en-US'],
   theme: 'system',
-  trayEnabled: false
+  trayEnabled: false,
+  closeButtonBehavior: CLOSE_BUTTON_BEHAVIORS.quit
 };
 
 const webPreferences = {
diff --git a/src/settings/settings.browser.css b/src/settings/settings.browser.css
index 3e4a247..298ccdc 100644
--- a/src/settings/settings.browser.css
+++ b/src/settings/settings.browser.css
@@ -16,7 +16,7 @@
 @import '../styles/main.css';
 
 :root {
-    --border-color: #dbdbdb;
+    --settings-row-height: 56px;
     overflow-y: auto;
 }
 
@@ -80,6 +80,16 @@
     flex: 1;
 }
 
+.settings__row {
+    display: flex;
+    align-items: center;
+    height: var(--settings-row-height);
+}
+
+.settings .material3.select {
+    min-width: 180px;
+}
+
 .settings__option {
     --x-margin: calc(-1 * var(--material3-card-padding));
     margin: 0 var(--x-margin);
@@ -94,7 +104,7 @@
     display: flex;
     align-items: center;
     line-height: var(--md-sys-typescale-body-large-line-height);
-    height: 56px;
+    height: var(--settings-row-height);
     gap: 6px;
     padding: 0 var(--material3-card-padding);
 }
diff --git a/src/settings/settings.browser.mjs b/src/settings/settings.browser.mjs
index 434246b..cb15d12 100644
--- a/src/settings/settings.browser.mjs
+++ b/src/settings/settings.browser.mjs
@@ -43,7 +43,8 @@ const Settings = ({initialState}) => {
     enabledDictionaries,
     disableNotificationsGlobally: state.disableNotificationsGlobally,
     theme: state.theme,
-    trayEnabled: state.trayEnabled
+    trayEnabled: state.trayEnabled,
+    closeButtonBehavior: state.closeButtonBehavior
   });
   const cancel = () => ipcRenderer.send(APP_EVENTS.closeDialog);
   return html`
@@ -93,7 +94,8 @@ Promise.all([
       newTabValue: '',
       disableNotificationsGlobally: currentSettings.disableNotificationsGlobally,
       theme: currentSettings.theme,
-      trayEnabled: currentSettings.trayEnabled
+      trayEnabled: currentSettings.trayEnabled,
+      closeButtonBehavior: currentSettings.closeButtonBehavior
     };
     render(html`<${Settings} initialState=${initialState} />`, settingsRoot());
   }
diff --git a/src/settings/settings.common.browser.mjs b/src/settings/settings.common.browser.mjs
index 0cd334d..8a7c7b0 100644
--- a/src/settings/settings.common.browser.mjs
+++ b/src/settings/settings.common.browser.mjs
@@ -57,3 +57,7 @@ export const SettingsOption = ({
     </div>
   </div>
 `;
+
+export const SettingsRow = ({children}) => html`
+  <div class='settings__row'>${children}</div>
+`;
diff --git a/src/settings/settings.other.browser.mjs b/src/settings/settings.other.browser.mjs
index 7ded4b3..9bba8d4 100644
--- a/src/settings/settings.other.browser.mjs
+++ b/src/settings/settings.other.browser.mjs
@@ -13,49 +13,66 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-import {ELECTRONIM_VERSION, html, Card, Icon, Select} from '../components/index.mjs';
+import {CLOSE_BUTTON_BEHAVIORS, ELECTRONIM_VERSION, html, Card, Icon, Select} from '../components/index.mjs';
 import {
   isPaneActive,
-  setTheme,
+  closeButtonBehavior,
+  setProperty,
+  theme,
   toggleNotifications,
   toggleTray
 } from './settings.reducer.browser.mjs';
-import {SettingsOption} from './settings.common.browser.mjs';
+import {SettingsOption, SettingsRow} from './settings.common.browser.mjs';
 
-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}>
-`;
+export const OtherPane = ({dispatch, state}) => {
+  const dispatchSetProperty = setProperty({dispatch});
+  const setTheme = e => dispatchSetProperty({property: 'theme', value: e.target.value});
+  const setCloseButtonBehavior = e => dispatchSetProperty({property: 'closeButtonBehavior', value: e.target.value});
+  return isPaneActive(state)(OtherPane.id) && html`
+    <h2 class='title'>Other</h2>
+    <${Card} className='settings__other'>
+      <${SettingsRow}>
+        <${Select}
+          data-testid='settings-theme-select'
+          label='Theme' value=${theme(state)} onChange=${setTheme}
+        >
+          <${Select.Option} value='system'>system</${Select.Option}>
+          <${Select.Option} value='light'>light</${Select.Option}>
+          <${Select.Option} value='dark'>dark</${Select.Option}>
+        </${Select}>
+      </${SettingsRow}>
+      <${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} />
+      <${SettingsRow}>
+        <${Select}
+            data-testid='settings-close-button-behavior-select'
+            label='Close button behavior'
+            value=${closeButtonBehavior(state)} onChange=${setCloseButtonBehavior}
+        >
+          <${Select.Option} value=${CLOSE_BUTTON_BEHAVIORS.quit}>Quit</${Select.Option}>
+          <${Select.Option} value=${CLOSE_BUTTON_BEHAVIORS.minimize}>Minimize</${Select.Option}>
+        </${Select}>
+      </${SettingsRow}>
+      <${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 dc824fa..3875e1e 100644
--- a/src/settings/settings.reducer.browser.mjs
+++ b/src/settings/settings.reducer.browser.mjs
@@ -19,8 +19,8 @@ export const ACTIONS = {
   ACTIVATE_PANE: 'ACTIVATE_PANE',
   ADD: 'ADD',
   REMOVE: 'REMOVE',
+  SET_PROPERTY: 'SET_PROPERTY',
   SET_TAB_PROPERTY: 'SET_TAB_PROPERTY',
-  SET_THEME: 'SET_THEME',
   TOGGLE_USE_NATIVE_SPELL_CHECKER: 'TOGGLE_USE_NATIVE_SPELL_CHECKER',
   TOGGLE_DICTIONARY: 'TOGGLE_DICTIONARY',
   TOGGLE_GLOBAL_NOTIFICATIONS: 'TOGGLE_GLOBAL_NOTIFICATIONS',
@@ -32,6 +32,8 @@ export const ACTIONS = {
 
 // Selectors
 export const isPaneActive = state => paneId => state.activePane === paneId;
+export const closeButtonBehavior = state => state.closeButtonBehavior;
+export const theme = state => state.theme;
 export const useNativeSpellChecker = state => state.useNativeSpellChecker;
 export const dictionariesEnabled = state => state.dictionaries.enabled;
 const dictionariesAvailableNative = state => state.dictionaries.availableNative;
@@ -92,10 +94,10 @@ export const reducer = (state, action) => {
       });
       return newState;
     }
-    case ACTIONS.SET_THEME: {
-      return {...state,
-        theme: action.payload.theme
-      };
+    case ACTIONS.SET_PROPERTY: {
+      const newState = {...state};
+      newState[action.payload.property] = action.payload.value;
+      return newState;
     }
     case ACTIONS.TOGGLE_USE_NATIVE_SPELL_CHECKER: {
       return {...state,
@@ -160,8 +162,8 @@ export const addTab = ({dispatch}) =>
   () => dispatch({type: ACTIONS.ADD});
 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 setProperty = ({dispatch}) =>
+  ({property, value}) => dispatch({type: ACTIONS.SET_PROPERTY, payload: {property, value}});
 export const toggleDictionary = ({dispatch, languageKey}) =>
   () => dispatch({type: ACTIONS.TOGGLE_DICTIONARY, payload: languageKey});
 export const toggleNotifications = ({dispatch}) =>
diff --git a/webpack.js b/webpack.js
index 4635b1a..cc4068e 100755
--- a/webpack.js
+++ b/webpack.js
@@ -20,7 +20,7 @@ const path = require('path');
 const fsp = require('fs/promises');
 const fs = require('fs');
 
-const {APP_EVENTS, ELECTRONIM_VERSION} = require('./src/constants');
+const {APP_EVENTS, CLOSE_BUTTON_BEHAVIORS, ELECTRONIM_VERSION} = require('./src/constants');
 
 const BUNDLES_DIR = 'bundles';
 
@@ -161,6 +161,7 @@ const esmBundle = webpack({
   plugins: [
     new webpack.DefinePlugin({
       APP_EVENTS_TO_BE_REPLACED: JSON.stringify(APP_EVENTS),
+      CLOSE_BUTTON_BEHAVIORS_TO_BE_REPLACED: JSON.stringify(CLOSE_BUTTON_BEHAVIORS),
       ELECTRONIM_VERSION_TO_BE_REPLACED: JSON.stringify(ELECTRONIM_VERSION)
     })
   ]


Test patch:
diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js
index dc07faf..a07f206 100644
--- a/src/__tests__/electron.js
+++ b/src/__tests__/electron.js
@@ -22,6 +22,7 @@ const mockBrowserWindowInstance = () => {
     getContentBounds: jest.fn(() => ({})),
     isFullScreen: jest.fn(),
     loadURL: jest.fn(),
+    minimize: jest.fn(),
     on: jest.fn((eventName, func) => {
       instance.listeners[eventName] = func;
     }),
@@ -71,7 +72,7 @@ const mockElectronInstance = ({...overriddenProps} = {}) => {
     app: {
       getPath: jest.fn(),
       on: jest.fn(),
-      quit: jest.fn(),
+      exit: jest.fn(),
       setPath: jest.fn()
     },
     contextBridge: {
diff --git a/src/main/__tests__/global-listeners.test.js b/src/main/__tests__/global-listeners.test.js
index 5e6429b..a11a60a 100644
--- a/src/main/__tests__/global-listeners.test.js
+++ b/src/main/__tests__/global-listeners.test.js
@@ -147,7 +147,7 @@ describe('Main :: Global listeners test suite', () => {
     // When
     eventBus.listeners.quit();
     // Then
-    expect(electron.app.quit).toHaveBeenCalledTimes(1);
+    expect(electron.app.exit).toHaveBeenCalledTimes(1);
   });
   test('settingsOpenDialog, should open settings dialog', () => {
     // When
diff --git a/src/main/__tests__/main-window-listeners.test.js b/src/main/__tests__/main-window-listeners.test.js
new file mode 100644
index 0000000..ba44908
--- /dev/null
+++ b/src/main/__tests__/main-window-listeners.test.js
@@ -0,0 +1,69 @@
+/**
+ * @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 :: Main window listeners test suite', () => {
+  let electron;
+  let main;
+  let browserWindow;
+  beforeEach(() => {
+    jest.resetModules();
+    // Always mock settings unless we want to overwrite the real settings file !
+    jest.mock('../../settings');
+    require('../../settings').loadSettings.mockImplementation(() => ({}));
+    jest.mock('electron', () => require('../../__tests__').mockElectronInstance({
+      // Return a **different** instance for each view
+      BrowserView: jest.fn(() => require('../../__tests__').mockBrowserWindowInstance())
+    }));
+    electron = require('electron');
+    browserWindow = electron.browserWindowInstance;
+    jest.spyOn(require('../../user-agent'), 'initBrowserVersions')
+      .mockImplementation(() => Promise.resolve({}));
+    main = require('../');
+    main.init();
+  });
+  describe('close', () => {
+    let event;
+    beforeEach(() => {
+      event = {preventDefault: jest.fn()};
+    });
+    test('always calls event.preventDefault', () => {
+      // When
+      browserWindow.listeners.close(event);
+      // Then
+      expect(event.preventDefault).toHaveBeenCalled();
+    });
+    test('with quit, should exit app', () => {
+      // When
+      browserWindow.listeners.close(event);
+      // Then
+      expect(electron.app.exit).toHaveBeenCalled();
+      expect(browserWindow.minimize).not.toHaveBeenCalled();
+    });
+    test('with minimize, should minimize the window', () => {
+      // Given
+      require('../../settings').loadSettings.mockImplementation(() => ({
+        closeButtonBehavior: 'minimize'
+      }));
+      // When
+      browserWindow.listeners.close(event);
+      // Then
+      expect(electron.app.exit).not.toHaveBeenCalled();
+      expect(browserWindow.minimize).toHaveBeenCalled();
+    });
+  });
+});
diff --git a/src/settings/__tests__/index.test.js b/src/settings/__tests__/index.test.js
index fdd9ed3..561245e 100644
--- a/src/settings/__tests__/index.test.js
+++ b/src/settings/__tests__/index.test.js
@@ -21,12 +21,14 @@ describe('Settings module test suite', () => {
   let settings;
   beforeEach(() => {
     jest.resetModules();
-    jest.mock('../../constants', () => ({}));
     jest.mock('electron', () => require('../../__tests__').mockElectronInstance());
     electron = require('electron');
-    jest.mock('fs');
     jest.mock('os', () => ({homedir: () => '$HOME'}));
     fs = require('fs');
+    jest.spyOn(fs, 'existsSync');
+    jest.spyOn(fs, 'readFileSync');
+    jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
+    jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {});
     path = require('path');
     jest.spyOn(path, 'join');
     settings = require('../');
@@ -39,7 +41,7 @@ describe('Settings module test suite', () => {
       const result = settings.loadSettings();
       // Then
       expectHomeDirectoryCreated();
-      expect(fs.readFileSync).not.toHaveBeenCalled();
+      expect(fs.readFileSync).toHaveBeenCalledTimes(1);
       expect(result.tabs).toEqual([]);
       expect(result.enabledDictionaries).toEqual(['en-US']);
       expect(result.theme).toEqual('system');
@@ -52,7 +54,6 @@ describe('Settings module test suite', () => {
       const result = settings.loadSettings();
       // Then
       expectHomeDirectoryCreated();
-      expect(fs.readFileSync).toHaveBeenCalledTimes(1);
       expect(fs.readFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'));
       expect(result.tabs).toEqual([]);
       expect(result.enabledDictionaries).toEqual(['en-US']);
@@ -67,7 +68,6 @@ describe('Settings module test suite', () => {
       const result = settings.loadSettings();
       // Then
       expectHomeDirectoryCreated();
-      expect(fs.readFileSync).toHaveBeenCalledTimes(1);
       expect(fs.readFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'));
       expect(result.tabs).toEqual([{id: '1'}]);
       expect(result.enabledDictionaries).toEqual(['en-US']);
@@ -108,8 +108,12 @@ 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' +
-        '  "theme": "system",\n  "trayEnabled": false\n}');
+        '{\n  "tabs": [],\n' +
+        '  "useNativeSpellChecker": false,\n' +
+        '  "enabledDictionaries": [\n    "en-US"\n  ],\n' +
+        '  "theme": "system",\n  "trayEnabled": false,\n' +
+        '  "closeButtonBehavior": "quit"\n' +
+        '}');
     });
     test('object and saved settings, should overwrite overlapping settings', () => {
       // Given
@@ -117,14 +121,16 @@ describe('Settings module test suite', () => {
       // When
       settings.updateSettings({tabs: [{id: 1337}], activeTab: 1337, otherSetting: '1337'});
       // Then
-      expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
       expect(fs.writeFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'),
         '{\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}');
+        '  "closeButtonBehavior": "quit",\n' +
+        '  "activeTab": 1337,\n' +
+        '  "otherSetting": "1337"\n' +
+        '}');
     });
     test('object and saved settings with activeTab removed, should update activeTab', () => {
       // Given
@@ -132,13 +138,13 @@ describe('Settings module test suite', () => {
       // When
       settings.updateSettings({tabs: [{id: 1337}], activeTab: 31337});
       // Then
-      expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
       expect(fs.writeFileSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim', 'settings.json'),
         '{\n  "tabs": [\n    {\n      "id": 1337\n    }\n  ],\n' +
         '  "useNativeSpellChecker": false,\n' +
         '  "enabledDictionaries": [\n    "en-US"\n  ],\n' +
         '  "theme": "system",\n' +
         '  "trayEnabled": false,\n' +
+        '  "closeButtonBehavior": "quit",\n' +
         '  "activeTab": 1337\n}');
     });
   });
@@ -170,7 +176,6 @@ describe('Settings module test suite', () => {
     });
   });
   const expectHomeDirectoryCreated = () => {
-    expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
     expect(fs.mkdirSync).toHaveBeenCalledWith(path.join('$HOME', '.electronim'), {recursive: true});
   };
 });
diff --git a/src/settings/__tests__/settings.other.browser.test.mjs b/src/settings/__tests__/settings.other.browser.test.mjs
index 97aa920..d54b4ad 100644
--- a/src/settings/__tests__/settings.other.browser.test.mjs
+++ b/src/settings/__tests__/settings.other.browser.test.mjs
@@ -72,6 +72,22 @@ describe('Settings (Other) in Browser test suite', () => {
       await waitFor(() => expect($notificationsSwitch.checked).toBe(false));
     });
   });
+  describe('Close button behavior selection', () => {
+    let $closeButtonBehaviorContainer;
+    beforeEach(async () => {
+      $closeButtonBehaviorContainer = await findByTestId(document, 'settings-close-button-behavior-select');
+    });
+    test('Select shows quit', () => {
+      expect($closeButtonBehaviorContainer.querySelector('select').value).toBe('quit');
+    });
+    test('Different behavior can be selected', async () => {
+      await user.selectOptions($closeButtonBehaviorContainer.querySelector('select'), 'minimize');
+      await user.click(document.querySelector('.settings__submit'));
+      expect(mockIpcRenderer.send).toHaveBeenCalledWith('settingsSave', expect.objectContaining({
+        closeButtonBehavior: 'minimize'
+      }));
+    }, 10000);
+  });
   describe('Toggle System Tray click', () => {
     let $traySwitch;
     beforeEach(() => {