aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-29 18:19:39 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:54:56 +0200
commita35d560f8d1ca414e3ba387b50731ca099e2da47 (patch)
tree5a8a90345c04eb0b69ea3fca8185641e26905b5d
parentfeat: New window banner (diff)
downloadsophie-a35d560f8d1ca414e3ba387b50731ca099e2da47.tar.gz
sophie-a35d560f8d1ca414e3ba387b50731ca099e2da47.tar.zst
sophie-a35d560f8d1ca414e3ba387b50731ca099e2da47.zip
feat: Add custom menubar
The menu is populated reactive from the store with no caching. This doesn't seem to cause any performance problems so far. Currently the menu is electron-specific. In the future, we'll need a more runtime-independent way to build the menu. Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/index.ts8
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts4
-rw-r--r--packages/main/src/infrastructure/electron/impl/electronShell.ts5
-rw-r--r--packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts165
-rw-r--r--packages/main/src/infrastructure/electron/types.ts2
-rw-r--r--packages/main/src/initReactions.ts3
-rw-r--r--packages/main/src/stores/GlobalSettings.ts43
-rw-r--r--packages/main/src/stores/MainEnv.ts2
-rw-r--r--packages/main/src/stores/MainStore.ts9
-rw-r--r--packages/main/src/stores/Service.ts3
-rw-r--r--packages/main/src/stores/SharedStore.ts53
11 files changed, 276 insertions, 21 deletions
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 869c555..29a8cca 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -19,7 +19,7 @@
19 * SPDX-License-Identifier: AGPL-3.0-only 19 * SPDX-License-Identifier: AGPL-3.0-only
20 */ 20 */
21 21
22import { arch } from 'node:os'; 22import os from 'node:os';
23 23
24import { app } from 'electron'; 24import { app } from 'electron';
25import { ensureDirSync } from 'fs-extra'; 25import { ensureDirSync } from 'fs-extra';
@@ -68,7 +68,7 @@ app.setAboutPanelOptions({
68 `Chrome: ${process.versions.chrome}`, 68 `Chrome: ${process.versions.chrome}`,
69 `Node.js: ${process.versions.node}`, 69 `Node.js: ${process.versions.node}`,
70 `Platform: ${osName()}`, 70 `Platform: ${osName()}`,
71 `Arch: ${arch()}`, 71 `Arch: ${os.arch()}`,
72 `Build date: ${new Date( 72 `Build date: ${new Date(
73 Number(import.meta.env.BUILD_DATE), 73 Number(import.meta.env.BUILD_DATE),
74 ).toLocaleString()}`, 74 ).toLocaleString()}`,
@@ -90,7 +90,9 @@ app.on('window-all-closed', () => {
90 } 90 }
91}); 91});
92 92
93initReactions(store, isDevelopment) 93const isMac = os.platform() === 'darwin';
94
95initReactions(store, isDevelopment, isMac)
94 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await. 96 // eslint-disable-next-line promise/always-return -- `then` instead of top-level await.
95 .then((disposeCompositionRoot) => { 97 .then((disposeCompositionRoot) => {
96 app.on('will-quit', disposeCompositionRoot); 98 app.on('will-quit', disposeCompositionRoot);
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
index 089e63a..5062da0 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
@@ -178,6 +178,10 @@ export default class ElectronServiceView implements ServiceView {
178 this.browserView.webContents.stop(); 178 this.browserView.webContents.stop();
179 } 179 }
180 180
181 toggleDeveloperTools(): void {
182 this.browserView.webContents.toggleDevTools();
183 }
184
181 setBounds(bounds: BrowserViewBounds): void { 185 setBounds(bounds: BrowserViewBounds): void {
182 this.browserView.setBounds(bounds); 186 this.browserView.setBounds(bounds);
183 } 187 }
diff --git a/packages/main/src/infrastructure/electron/impl/electronShell.ts b/packages/main/src/infrastructure/electron/impl/electronShell.ts
index 0e8c0c1..be1cbe3 100644
--- a/packages/main/src/infrastructure/electron/impl/electronShell.ts
+++ b/packages/main/src/infrastructure/electron/impl/electronShell.ts
@@ -18,7 +18,7 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { shell } from 'electron'; 21import { app, shell } from 'electron';
22import { getLogger } from 'loglevel'; 22import { getLogger } from 'loglevel';
23 23
24import type MainEnv from '../../../stores/MainEnv'; 24import type MainEnv from '../../../stores/MainEnv';
@@ -31,6 +31,9 @@ const electronShell: MainEnv = {
31 log.error('Failed to open', url, 'in external program', error); 31 log.error('Failed to open', url, 'in external program', error);
32 }); 32 });
33 }, 33 },
34 openAboutDialog(): void {
35 app.showAboutPanel();
36 },
34}; 37};
35 38
36export default electronShell; 39export default electronShell;
diff --git a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
new file mode 100644
index 0000000..5166719
--- /dev/null
+++ b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
@@ -0,0 +1,165 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { Menu, MenuItemConstructorOptions } from 'electron';
22import { autorun } from 'mobx';
23import { addDisposer } from 'mobx-state-tree';
24
25import type MainStore from '../../../stores/MainStore';
26
27export default function setApplicationMenu(
28 store: MainStore,
29 devMode: boolean,
30 isMac: boolean,
31): void {
32 const dispose = autorun(() => {
33 const { settings, shared, visibleService } = store;
34 const { showLocationBar, selectedService } = settings;
35 const { canSwitchServices, services } = shared;
36
37 const template: MenuItemConstructorOptions[] = [
38 ...(isMac ? ([{ role: 'appMenu' }] as MenuItemConstructorOptions[]) : []),
39 { role: 'fileMenu' },
40 { role: 'editMenu' },
41 {
42 role: 'viewMenu',
43 submenu: [
44 {
45 label: 'Show Location Bar',
46 accelerator: 'CommandOrControl+Shift+L',
47 type: 'checkbox',
48 checked: showLocationBar,
49 click() {
50 settings.toggleLocationBar();
51 },
52 },
53 { type: 'separator' },
54 {
55 label: 'Reload',
56 accelerator: 'CommandOrControl+R',
57 enabled: selectedService !== undefined,
58 click() {
59 selectedService?.reload(false);
60 },
61 },
62 {
63 label: 'Force Reload',
64 accelerator: 'CommandOrControl+Shift+R',
65 enabled: selectedService !== undefined,
66 click() {
67 selectedService?.reload(true);
68 },
69 },
70 {
71 label: 'Toggle Developer Tools',
72 accelerator: 'CommandOrControl+Shift+I',
73 enabled: visibleService !== undefined,
74 click() {
75 visibleService?.toggleDeveloperTools();
76 },
77 },
78 { type: 'separator' },
79 ...(devMode
80 ? ([
81 {
82 role: 'forceReload',
83 label: 'Reload Sophie',
84 accelerator: 'CommandOrControl+Shift+Alt+R',
85 },
86 {
87 role: 'toggleDevTools',
88 label: 'Toggle Sophie Developer Tools',
89 accelerator: 'CommandOrControl+Shift+Alt+I',
90 },
91 { type: 'separator' },
92 ] as MenuItemConstructorOptions[])
93 : []),
94 { role: 'togglefullscreen' },
95 ],
96 },
97 {
98 label: 'Services',
99 submenu: [
100 {
101 label: 'Next Service',
102 accelerator: 'CommandOrControl+Tab',
103 enabled: canSwitchServices,
104 click() {
105 shared.activateNextService();
106 },
107 },
108 {
109 label: 'Previous Service',
110 accelerator: 'CommandOrControl+Shift+Tab',
111 enabled: canSwitchServices,
112 click() {
113 shared.activatePreviousService();
114 },
115 },
116 ...(services.length > 0
117 ? ([
118 { type: 'separator' },
119 ...services.map(
120 (service, index): MenuItemConstructorOptions => ({
121 label: service.config.name,
122 type: 'radio',
123 ...(index < 9
124 ? {
125 accelerator: `CommandOrControl+${index + 1}`,
126 }
127 : {}),
128 checked: selectedService === service,
129 click() {
130 settings.setSelectedService(service);
131 },
132 }),
133 ),
134 ] as MenuItemConstructorOptions[])
135 : []),
136 ],
137 },
138 { role: 'windowMenu' },
139 {
140 role: 'help',
141 submenu: [
142 {
143 label: 'Gitlab',
144 click() {
145 store.openWebpageInBrowser();
146 },
147 },
148 ...(isMac
149 ? []
150 : ([
151 {
152 role: 'about',
153 click() {
154 store.openAboutDialog();
155 },
156 },
157 ] as MenuItemConstructorOptions[])),
158 ],
159 },
160 ];
161 const menu = Menu.buildFromTemplate(template);
162 Menu.setApplicationMenu(menu);
163 });
164 addDisposer(store, dispose);
165}
diff --git a/packages/main/src/infrastructure/electron/types.ts b/packages/main/src/infrastructure/electron/types.ts
index 7b04a6b..4716f0b 100644
--- a/packages/main/src/infrastructure/electron/types.ts
+++ b/packages/main/src/infrastructure/electron/types.ts
@@ -65,6 +65,8 @@ export interface ServiceView {
65 65
66 stop(): void; 66 stop(): void;
67 67
68 toggleDeveloperTools(): void;
69
68 setBounds(bounds: BrowserViewBounds): void; 70 setBounds(bounds: BrowserViewBounds): void;
69 71
70 dispose(): void; 72 dispose(): void;
diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts
index b6a8502..9c49fc5 100644
--- a/packages/main/src/initReactions.ts
+++ b/packages/main/src/initReactions.ts
@@ -25,6 +25,7 @@ import UserAgents from './infrastructure/electron/UserAgents';
25import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; 25import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory';
26import { installDevToolsExtensions } from './infrastructure/electron/impl/devTools'; 26import { installDevToolsExtensions } from './infrastructure/electron/impl/devTools';
27import hardenSession from './infrastructure/electron/impl/hardenSession'; 27import hardenSession from './infrastructure/electron/impl/hardenSession';
28import setApplicationMenu from './infrastructure/electron/impl/setApplicationMenu';
28import getDistResources from './infrastructure/resources/impl/getDistResources'; 29import getDistResources from './infrastructure/resources/impl/getDistResources';
29import loadServices from './reactions/loadServices'; 30import loadServices from './reactions/loadServices';
30import synchronizeConfig from './reactions/synchronizeConfig'; 31import synchronizeConfig from './reactions/synchronizeConfig';
@@ -35,6 +36,7 @@ import type Disposer from './utils/Disposer';
35export default async function initReactions( 36export default async function initReactions(
36 store: MainStore, 37 store: MainStore,
37 devMode: boolean, 38 devMode: boolean,
39 isMac: boolean,
38): Promise<Disposer> { 40): Promise<Disposer> {
39 const configRepository = new ConfigFile(app.getPath('userData')); 41 const configRepository = new ConfigFile(app.getPath('userData'));
40 const disposeConfigController = await synchronizeConfig( 42 const disposeConfigController = await synchronizeConfig(
@@ -50,6 +52,7 @@ export default async function initReactions(
50 } 52 }
51 const userAgents = new UserAgents(app.userAgentFallback); 53 const userAgents = new UserAgents(app.userAgentFallback);
52 app.userAgentFallback = userAgents.fallbackUserAgent(devMode); 54 app.userAgentFallback = userAgents.fallbackUserAgent(devMode);
55 setApplicationMenu(store, devMode, isMac);
53 const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); 56 const viewFactory = new ElectronViewFactory(userAgents, resources, devMode);
54 const [mainWindow] = await Promise.all([ 57 const [mainWindow] = await Promise.all([
55 viewFactory.createMainWindow(store), 58 viewFactory.createMainWindow(store),
diff --git a/packages/main/src/stores/GlobalSettings.ts b/packages/main/src/stores/GlobalSettings.ts
index 31b7e12..4e6aa13 100644
--- a/packages/main/src/stores/GlobalSettings.ts
+++ b/packages/main/src/stores/GlobalSettings.ts
@@ -27,23 +27,32 @@ import Service from './Service';
27 27
28const log = getLogger('sharedStore'); 28const log = getLogger('sharedStore');
29 29
30const GlobalSettings = defineGlobalSettingsModel(Service).actions((self) => ({ 30const GlobalSettings = defineGlobalSettingsModel(Service)
31 setThemeSource(mode: ThemeSource): void { 31 .actions((self) => ({
32 self.themeSource = mode; 32 setThemeSource(mode: ThemeSource): void {
33 }, 33 self.themeSource = mode;
34 setShowLocationBar(showLocationBar: boolean): void { 34 },
35 self.showLocationBar = showLocationBar; 35 setShowLocationBar(showLocationBar: boolean): void {
36 }, 36 self.showLocationBar = showLocationBar;
37 setSelectedServiceId(serviceId: string): void { 37 },
38 const serviceInstance = resolveIdentifier(Service, self, serviceId); 38 setSelectedService(service: Service): void {
39 if (serviceInstance === undefined) { 39 self.selectedService = service;
40 log.warn('Trying to select unknown service', serviceId); 40 },
41 return; 41 }))
42 } 42 .actions((self) => ({
43 self.selectedService = serviceInstance; 43 toggleLocationBar(): void {
44 log.debug('Selected service', serviceId); 44 self.setShowLocationBar(!self.showLocationBar);
45 }, 45 },
46})); 46 setSelectedServiceId(serviceId: string): void {
47 const serviceInstance = resolveIdentifier(Service, self, serviceId);
48 if (serviceInstance === undefined) {
49 log.warn('Trying to select unknown service', serviceId);
50 return;
51 }
52 self.setSelectedService(serviceInstance);
53 log.debug('Selected service', serviceId);
54 },
55 }));
47 56
48/* 57/*
49 eslint-disable-next-line @typescript-eslint/no-redeclare -- 58 eslint-disable-next-line @typescript-eslint/no-redeclare --
diff --git a/packages/main/src/stores/MainEnv.ts b/packages/main/src/stores/MainEnv.ts
index 94c34f3..8923322 100644
--- a/packages/main/src/stores/MainEnv.ts
+++ b/packages/main/src/stores/MainEnv.ts
@@ -26,4 +26,6 @@ export function getEnv(model: IAnyStateTreeNode): MainEnv {
26 26
27export default interface MainEnv { 27export default interface MainEnv {
28 openURLInExternalBrowser(url: string): void; 28 openURLInExternalBrowser(url: string): void;
29
30 openAboutDialog(): void;
29} 31}
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index ed0f391..86fadcb 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -25,6 +25,7 @@ import type { MainWindow } from '../infrastructure/electron/types';
25import { getLogger } from '../utils/log'; 25import { getLogger } from '../utils/log';
26 26
27import GlobalSettings from './GlobalSettings'; 27import GlobalSettings from './GlobalSettings';
28import { getEnv } from './MainEnv';
28import Profile from './Profile'; 29import Profile from './Profile';
29import Service from './Service'; 30import Service from './Service';
30import SharedStore from './SharedStore'; 31import SharedStore from './SharedStore';
@@ -112,6 +113,14 @@ const MainStore = types
112 setMainWindow(mainWindow: MainWindow | undefined): void { 113 setMainWindow(mainWindow: MainWindow | undefined): void {
113 self.mainWindow = mainWindow; 114 self.mainWindow = mainWindow;
114 }, 115 },
116 openWebpageInBrowser() {
117 getEnv(self).openURLInExternalBrowser(
118 'https://gitlab.com/say-hi-to-sophie/shophie',
119 );
120 },
121 openAboutDialog() {
122 getEnv(self).openAboutDialog();
123 },
115 beforeDestroy(): void { 124 beforeDestroy(): void {
116 self.mainWindow?.dispose(); 125 self.mainWindow?.dispose();
117 }, 126 },
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index 0a35114..d8f3166 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -126,6 +126,9 @@ const Service = defineServiceModel(ServiceSettings)
126 dismissAllPopups(): void { 126 dismissAllPopups(): void {
127 self.popups.splice(0); 127 self.popups.splice(0);
128 }, 128 },
129 toggleDeveloperTools(): void {
130 self.serviceView?.toggleDeveloperTools();
131 },
129 })) 132 }))
130 .actions((self) => { 133 .actions((self) => {
131 function setState(state: ServiceStateSnapshotIn): void { 134 function setState(state: ServiceStateSnapshotIn): void {
diff --git a/packages/main/src/stores/SharedStore.ts b/packages/main/src/stores/SharedStore.ts
index d72c532..67d58d6 100644
--- a/packages/main/src/stores/SharedStore.ts
+++ b/packages/main/src/stores/SharedStore.ts
@@ -21,12 +21,16 @@
21import { defineSharedStoreModel } from '@sophie/shared'; 21import { defineSharedStoreModel } from '@sophie/shared';
22import { getSnapshot, Instance } from 'mobx-state-tree'; 22import { getSnapshot, Instance } from 'mobx-state-tree';
23 23
24import { getLogger } from '../utils/log';
25
24import GlobalSettings from './GlobalSettings'; 26import GlobalSettings from './GlobalSettings';
25import Profile from './Profile'; 27import Profile from './Profile';
26import Service from './Service'; 28import Service from './Service';
27import type Config from './config/Config'; 29import type Config from './config/Config';
28import loadConfig from './config/loadConfig'; 30import loadConfig from './config/loadConfig';
29 31
32const log = getLogger('SharedStore');
33
30function getConfigs<T>(models: { config: T }[]): T[] | undefined { 34function getConfigs<T>(models: { config: T }[]): T[] | undefined {
31 return models.length === 0 ? undefined : models.map((model) => model.config); 35 return models.length === 0 ? undefined : models.map((model) => model.config);
32} 36}
@@ -42,6 +46,19 @@ const SharedStore = defineSharedStoreModel(GlobalSettings, Profile, Service)
42 services: getConfigs(services), 46 services: getConfigs(services),
43 }; 47 };
44 }, 48 },
49 get canSwitchServices(): boolean {
50 return self.services.length >= 2;
51 },
52 get selectedServiceIndex(): number {
53 const {
54 services,
55 settings: { selectedService },
56 } = self;
57 if (selectedService === undefined) {
58 return -1;
59 }
60 return services.indexOf(selectedService);
61 },
45 })) 62 }))
46 .actions((self) => ({ 63 .actions((self) => ({
47 loadConfig(config: Config): void { 64 loadConfig(config: Config): void {
@@ -50,6 +67,42 @@ const SharedStore = defineSharedStoreModel(GlobalSettings, Profile, Service)
50 setShouldUseDarkColors(shouldUseDarkColors: boolean): void { 67 setShouldUseDarkColors(shouldUseDarkColors: boolean): void {
51 self.shouldUseDarkColors = shouldUseDarkColors; 68 self.shouldUseDarkColors = shouldUseDarkColors;
52 }, 69 },
70 activateServiceByOffset(offset: number): void {
71 if (offset === 0) {
72 return;
73 }
74 const { selectedServiceIndex: index, services, settings } = self;
75 if (index < 0) {
76 log.warn('No selected service to offset');
77 return;
78 }
79 const { length } = services;
80 const indexWithOffset = (index + offset) % length;
81 // Make sure that `newIndex` is positive even for large negative `offset`.
82 const newIndex =
83 indexWithOffset < 0 ? indexWithOffset + length : indexWithOffset;
84 const newService = services.at(newIndex);
85 if (newService === undefined) {
86 log.error(
87 'Could not advance selected service index from',
88 index,
89 'by',
90 offset,
91 'to',
92 newIndex,
93 );
94 return;
95 }
96 settings.setSelectedService(newService);
97 },
98 }))
99 .actions((self) => ({
100 activateNextService(): void {
101 self.activateServiceByOffset(1);
102 },
103 activatePreviousService(): void {
104 self.activateServiceByOffset(-1);
105 },
53 })); 106 }));
54 107
55/* 108/*