From a35d560f8d1ca414e3ba387b50731ca099e2da47 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 29 Mar 2022 18:19:39 +0200 Subject: feat: Add custom menubar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/main/src/index.ts | 8 +- .../electron/impl/ElectronServiceView.ts | 4 + .../infrastructure/electron/impl/electronShell.ts | 5 +- .../electron/impl/setApplicationMenu.ts | 165 +++++++++++++++++++++ packages/main/src/infrastructure/electron/types.ts | 2 + packages/main/src/initReactions.ts | 3 + packages/main/src/stores/GlobalSettings.ts | 43 +++--- packages/main/src/stores/MainEnv.ts | 2 + packages/main/src/stores/MainStore.ts | 9 ++ packages/main/src/stores/Service.ts | 3 + packages/main/src/stores/SharedStore.ts | 53 +++++++ 11 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts 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 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { arch } from 'node:os'; +import os from 'node:os'; import { app } from 'electron'; import { ensureDirSync } from 'fs-extra'; @@ -68,7 +68,7 @@ app.setAboutPanelOptions({ `Chrome: ${process.versions.chrome}`, `Node.js: ${process.versions.node}`, `Platform: ${osName()}`, - `Arch: ${arch()}`, + `Arch: ${os.arch()}`, `Build date: ${new Date( Number(import.meta.env.BUILD_DATE), ).toLocaleString()}`, @@ -90,7 +90,9 @@ app.on('window-all-closed', () => { } }); -initReactions(store, isDevelopment) +const isMac = os.platform() === 'darwin'; + +initReactions(store, isDevelopment, isMac) // eslint-disable-next-line promise/always-return -- `then` instead of top-level await. .then((disposeCompositionRoot) => { 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 { this.browserView.webContents.stop(); } + toggleDeveloperTools(): void { + this.browserView.webContents.toggleDevTools(); + } + setBounds(bounds: BrowserViewBounds): void { this.browserView.setBounds(bounds); } 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 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { shell } from 'electron'; +import { app, shell } from 'electron'; import { getLogger } from 'loglevel'; import type MainEnv from '../../../stores/MainEnv'; @@ -31,6 +31,9 @@ const electronShell: MainEnv = { log.error('Failed to open', url, 'in external program', error); }); }, + openAboutDialog(): void { + app.showAboutPanel(); + }, }; export 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 @@ +/* + * Copyright (C) 2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Menu, MenuItemConstructorOptions } from 'electron'; +import { autorun } from 'mobx'; +import { addDisposer } from 'mobx-state-tree'; + +import type MainStore from '../../../stores/MainStore'; + +export default function setApplicationMenu( + store: MainStore, + devMode: boolean, + isMac: boolean, +): void { + const dispose = autorun(() => { + const { settings, shared, visibleService } = store; + const { showLocationBar, selectedService } = settings; + const { canSwitchServices, services } = shared; + + const template: MenuItemConstructorOptions[] = [ + ...(isMac ? ([{ role: 'appMenu' }] as MenuItemConstructorOptions[]) : []), + { role: 'fileMenu' }, + { role: 'editMenu' }, + { + role: 'viewMenu', + submenu: [ + { + label: 'Show Location Bar', + accelerator: 'CommandOrControl+Shift+L', + type: 'checkbox', + checked: showLocationBar, + click() { + settings.toggleLocationBar(); + }, + }, + { type: 'separator' }, + { + label: 'Reload', + accelerator: 'CommandOrControl+R', + enabled: selectedService !== undefined, + click() { + selectedService?.reload(false); + }, + }, + { + label: 'Force Reload', + accelerator: 'CommandOrControl+Shift+R', + enabled: selectedService !== undefined, + click() { + selectedService?.reload(true); + }, + }, + { + label: 'Toggle Developer Tools', + accelerator: 'CommandOrControl+Shift+I', + enabled: visibleService !== undefined, + click() { + visibleService?.toggleDeveloperTools(); + }, + }, + { type: 'separator' }, + ...(devMode + ? ([ + { + role: 'forceReload', + label: 'Reload Sophie', + accelerator: 'CommandOrControl+Shift+Alt+R', + }, + { + role: 'toggleDevTools', + label: 'Toggle Sophie Developer Tools', + accelerator: 'CommandOrControl+Shift+Alt+I', + }, + { type: 'separator' }, + ] as MenuItemConstructorOptions[]) + : []), + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Services', + submenu: [ + { + label: 'Next Service', + accelerator: 'CommandOrControl+Tab', + enabled: canSwitchServices, + click() { + shared.activateNextService(); + }, + }, + { + label: 'Previous Service', + accelerator: 'CommandOrControl+Shift+Tab', + enabled: canSwitchServices, + click() { + shared.activatePreviousService(); + }, + }, + ...(services.length > 0 + ? ([ + { type: 'separator' }, + ...services.map( + (service, index): MenuItemConstructorOptions => ({ + label: service.config.name, + type: 'radio', + ...(index < 9 + ? { + accelerator: `CommandOrControl+${index + 1}`, + } + : {}), + checked: selectedService === service, + click() { + settings.setSelectedService(service); + }, + }), + ), + ] as MenuItemConstructorOptions[]) + : []), + ], + }, + { role: 'windowMenu' }, + { + role: 'help', + submenu: [ + { + label: 'Gitlab', + click() { + store.openWebpageInBrowser(); + }, + }, + ...(isMac + ? [] + : ([ + { + role: 'about', + click() { + store.openAboutDialog(); + }, + }, + ] as MenuItemConstructorOptions[])), + ], + }, + ]; + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + }); + addDisposer(store, dispose); +} 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 { stop(): void; + toggleDeveloperTools(): void; + setBounds(bounds: BrowserViewBounds): void; 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'; import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; import { installDevToolsExtensions } from './infrastructure/electron/impl/devTools'; import hardenSession from './infrastructure/electron/impl/hardenSession'; +import setApplicationMenu from './infrastructure/electron/impl/setApplicationMenu'; import getDistResources from './infrastructure/resources/impl/getDistResources'; import loadServices from './reactions/loadServices'; import synchronizeConfig from './reactions/synchronizeConfig'; @@ -35,6 +36,7 @@ import type Disposer from './utils/Disposer'; export default async function initReactions( store: MainStore, devMode: boolean, + isMac: boolean, ): Promise { const configRepository = new ConfigFile(app.getPath('userData')); const disposeConfigController = await synchronizeConfig( @@ -50,6 +52,7 @@ export default async function initReactions( } const userAgents = new UserAgents(app.userAgentFallback); app.userAgentFallback = userAgents.fallbackUserAgent(devMode); + setApplicationMenu(store, devMode, isMac); const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); const [mainWindow] = await Promise.all([ 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'; const log = getLogger('sharedStore'); -const GlobalSettings = defineGlobalSettingsModel(Service).actions((self) => ({ - setThemeSource(mode: ThemeSource): void { - self.themeSource = mode; - }, - setShowLocationBar(showLocationBar: boolean): void { - self.showLocationBar = showLocationBar; - }, - setSelectedServiceId(serviceId: string): void { - const serviceInstance = resolveIdentifier(Service, self, serviceId); - if (serviceInstance === undefined) { - log.warn('Trying to select unknown service', serviceId); - return; - } - self.selectedService = serviceInstance; - log.debug('Selected service', serviceId); - }, -})); +const GlobalSettings = defineGlobalSettingsModel(Service) + .actions((self) => ({ + setThemeSource(mode: ThemeSource): void { + self.themeSource = mode; + }, + setShowLocationBar(showLocationBar: boolean): void { + self.showLocationBar = showLocationBar; + }, + setSelectedService(service: Service): void { + self.selectedService = service; + }, + })) + .actions((self) => ({ + toggleLocationBar(): void { + self.setShowLocationBar(!self.showLocationBar); + }, + setSelectedServiceId(serviceId: string): void { + const serviceInstance = resolveIdentifier(Service, self, serviceId); + if (serviceInstance === undefined) { + log.warn('Trying to select unknown service', serviceId); + return; + } + self.setSelectedService(serviceInstance); + log.debug('Selected service', serviceId); + }, + })); /* 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 { export default interface MainEnv { openURLInExternalBrowser(url: string): void; + + openAboutDialog(): void; } 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'; import { getLogger } from '../utils/log'; import GlobalSettings from './GlobalSettings'; +import { getEnv } from './MainEnv'; import Profile from './Profile'; import Service from './Service'; import SharedStore from './SharedStore'; @@ -112,6 +113,14 @@ const MainStore = types setMainWindow(mainWindow: MainWindow | undefined): void { self.mainWindow = mainWindow; }, + openWebpageInBrowser() { + getEnv(self).openURLInExternalBrowser( + 'https://gitlab.com/say-hi-to-sophie/shophie', + ); + }, + openAboutDialog() { + getEnv(self).openAboutDialog(); + }, beforeDestroy(): void { self.mainWindow?.dispose(); }, 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) dismissAllPopups(): void { self.popups.splice(0); }, + toggleDeveloperTools(): void { + self.serviceView?.toggleDeveloperTools(); + }, })) .actions((self) => { 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 @@ import { defineSharedStoreModel } from '@sophie/shared'; import { getSnapshot, Instance } from 'mobx-state-tree'; +import { getLogger } from '../utils/log'; + import GlobalSettings from './GlobalSettings'; import Profile from './Profile'; import Service from './Service'; import type Config from './config/Config'; import loadConfig from './config/loadConfig'; +const log = getLogger('SharedStore'); + function getConfigs(models: { config: T }[]): T[] | undefined { return models.length === 0 ? undefined : models.map((model) => model.config); } @@ -42,6 +46,19 @@ const SharedStore = defineSharedStoreModel(GlobalSettings, Profile, Service) services: getConfigs(services), }; }, + get canSwitchServices(): boolean { + return self.services.length >= 2; + }, + get selectedServiceIndex(): number { + const { + services, + settings: { selectedService }, + } = self; + if (selectedService === undefined) { + return -1; + } + return services.indexOf(selectedService); + }, })) .actions((self) => ({ loadConfig(config: Config): void { @@ -50,6 +67,42 @@ const SharedStore = defineSharedStoreModel(GlobalSettings, Profile, Service) setShouldUseDarkColors(shouldUseDarkColors: boolean): void { self.shouldUseDarkColors = shouldUseDarkColors; }, + activateServiceByOffset(offset: number): void { + if (offset === 0) { + return; + } + const { selectedServiceIndex: index, services, settings } = self; + if (index < 0) { + log.warn('No selected service to offset'); + return; + } + const { length } = services; + const indexWithOffset = (index + offset) % length; + // Make sure that `newIndex` is positive even for large negative `offset`. + const newIndex = + indexWithOffset < 0 ? indexWithOffset + length : indexWithOffset; + const newService = services.at(newIndex); + if (newService === undefined) { + log.error( + 'Could not advance selected service index from', + index, + 'by', + offset, + 'to', + newIndex, + ); + return; + } + settings.setSelectedService(newService); + }, + })) + .actions((self) => ({ + activateNextService(): void { + self.activateServiceByOffset(1); + }, + activatePreviousService(): void { + self.activateServiceByOffset(-1); + }, })); /* -- cgit v1.2.3-54-g00ecf