diff options
Diffstat (limited to 'gui/src')
30 files changed, 272 insertions, 189 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index cbd56f3843..f3ac7e72c0 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -34,11 +34,12 @@ import { RelaySettingsUpdate, TunnelState, } from '../shared/daemon-rpc-types'; -import { loadTranslations, messages } from '../shared/gettext'; +import { messages, relayLocations } from '../shared/gettext'; import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; -import { IpcMainEventChannel } from '../shared/ipc-event-channel'; import log, { ConsoleOutput, Logger } from '../shared/logging'; import { LogLevel } from '../shared/logging-types'; +import { IpcMainEventChannel } from './ipc-event-channel'; +import { ICurrentAppVersionInfo } from '../shared/ipc-types'; import { AccountExpiredNotificationProvider, CloseToAccountExpiryNotificationProvider, @@ -65,11 +66,13 @@ import { IpcInput, OLD_LOG_FILES, } from './logging'; +import { loadTranslations } from './load-translations'; import NotificationController from './notification-controller'; import { resolveBin } from './proc'; import ReconnectionBackoff from './reconnection-backoff'; import TrayIconController, { TrayIconType } from './tray-icon-controller'; import WindowController from './window-controller'; +import { ITranslations } from '../shared/ipc-schema'; // Only import when running app on Linux. const linuxSplitTunneling = process.platform === 'linux' && require('./linux-split-tunneling'); @@ -88,13 +91,6 @@ enum AppQuitStage { ready, } -export interface ICurrentAppVersionInfo { - gui: string; - daemon: string; - isConsistent: boolean; - isBeta: boolean; -} - type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error }; class ApplicationMain { @@ -204,6 +200,7 @@ class ApplicationMain { private autoConnectFallbackScheduler = new Scheduler(); private rendererLog?: Logger; + private translations: ITranslations = { locale: this.locale }; public run() { // Remove window animations to combat window flickering when opening window. Can be removed when @@ -212,6 +209,10 @@ class ApplicationMain { app.commandLine.appendSwitch('wm-window-animations-disabled'); } + if (process.platform !== 'linux') { + app.enableSandbox(); + } + this.overrideAppPaths(); if (this.ensureSingleInstance()) { @@ -373,7 +374,7 @@ class ApplicationMain { this.blockRequests(); - this.updateCurrentLocale(); + this.translations = this.updateCurrentLocale(); this.daemonRpc.addConnectionObserver( new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected), @@ -1007,6 +1008,9 @@ class ApplicationMain { upgradeVersion: this.upgradeVersion, guiSettings: this.guiSettings.state, wireguardPublicKey: this.wireguardPublicKey, + translations: this.translations, + platform: process.platform, + runningInDevelopment: process.env.NODE_ENV === 'development', })); IpcMainEventChannel.settings.handleSetAllowLan((allowLan: boolean) => @@ -1074,7 +1078,7 @@ class ApplicationMain { IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale: string) => { this.guiSettings.preferredLocale = locale; - this.didChangeLocale(); + return Promise.resolve(this.updateCurrentLocale()); }); IpcMainEventChannel.account.handleCreate(() => this.createNewAccount()); @@ -1368,15 +1372,13 @@ class ApplicationMain { log.info(`Detected locale: ${this.locale}`); - loadTranslations(this.locale, messages); - } - - private didChangeLocale() { - this.updateCurrentLocale(); - - if (this.windowController) { - IpcMainEventChannel.locale.notify(this.windowController.webContents, this.locale); - } + const messagesTranslations = loadTranslations(this.locale, messages); + const relayLocationsTranslations = loadTranslations(this.locale, relayLocations); + return { + locale: this.locale, + messages: messagesTranslations, + relayLocations: relayLocationsTranslations, + }; } // Since the app frontend never performs any network requests, all requests originating from the @@ -1446,11 +1448,15 @@ class ApplicationMain { transparent: !this.guiSettings.unpinnedWindow, useContentSize: true, webPreferences: { - nodeIntegration: true, - devTools: process.env.NODE_ENV === 'development', - // TODO: Remove use of remote - enableRemoteModule: true, + preload: path.join(__dirname, '../renderer/preloadBundle.js'), + nodeIntegration: false, + nodeIntegrationInWorker: false, + nodeIntegrationInSubFrames: false, + enableRemoteModule: false, + sandbox: process.platform !== 'linux', + contextIsolation: true, spellcheck: false, + devTools: process.env.NODE_ENV === 'development', }, }; diff --git a/gui/src/main/ipc-event-channel.ts b/gui/src/main/ipc-event-channel.ts new file mode 100644 index 0000000000..9bd8af1490 --- /dev/null +++ b/gui/src/main/ipc-event-channel.ts @@ -0,0 +1,5 @@ +import { ipcMain } from 'electron'; +import { createIpcMain } from '../shared/ipc-helpers'; +import { ipcSchema } from '../shared/ipc-schema'; + +export const IpcMainEventChannel = createIpcMain(ipcSchema, ipcMain); diff --git a/gui/src/main/linux-desktop-entry.ts b/gui/src/main/linux-desktop-entry.ts index dc1ae0e57b..a2712bb7e3 100644 --- a/gui/src/main/linux-desktop-entry.ts +++ b/gui/src/main/linux-desktop-entry.ts @@ -1,4 +1,5 @@ import child_process from 'child_process'; +import { nativeImage } from 'electron'; import fs from 'fs'; import path from 'path'; import { ILinuxApplication } from '../shared/application-types'; @@ -45,7 +46,7 @@ export async function getAppIcon(name?: string) { // Chromium doesn't support .xpm files const extensions = ['svg', 'png']; - return findIcon(name, extensions, [ + const iconPath = await findIcon(name, extensions, [ getIconDirectories(), await getGtkThemeDirectories(), // Begin with preferred sized but if nothing matches other sizes should be considered as well. @@ -53,6 +54,19 @@ export async function getAppIcon(name?: string) { // Search in all categories of icons. [/.*/], ]); + + if (iconPath && path.extname(iconPath) === '.svg') { + try { + const contents = await fs.promises.readFile(iconPath); + return `data:image/svg+xml;base64,${contents.toString('base64')}`; + } catch (error) { + log.error(`Failed to read icon of application: ${name},`, error); + } + } else if (iconPath) { + return nativeImage.createFromPath(iconPath).toDataURL(); + } + + return undefined; } // Implemented according to freedesktop specification. diff --git a/gui/src/main/load-translations.ts b/gui/src/main/load-translations.ts new file mode 100644 index 0000000000..63d76f0ac6 --- /dev/null +++ b/gui/src/main/load-translations.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import { GetTextTranslations, po } from 'gettext-parser'; +import Gettext from 'node-gettext'; +import path from 'path'; +import log from '../shared/logging'; + +const SOURCE_LANGUAGE = 'en'; +const LOCALES_DIR = path.resolve(__dirname, '../../locales'); + +export function loadTranslations( + currentLocale: string, + catalogue: Gettext, +): GetTextTranslations | undefined { + // First look for exact match of the current locale + const preferredLocales = []; + + if (currentLocale !== SOURCE_LANGUAGE) { + preferredLocales.push(currentLocale); + } + + // In case of region bound locale like en-US, fallback to en. + const language = Gettext.getLanguageCode(currentLocale); + if (currentLocale !== language) { + preferredLocales.push(language); + } + + const domain = catalogue.domain; + for (const locale of preferredLocales) { + const parsedTranslations = parseTranslation(locale, domain, catalogue); + if (parsedTranslations) { + log.info(`Loaded translations ${locale}/${domain}`); + catalogue.setLocale(locale); + return parsedTranslations; + } + } + + // Reset the locale to source language if we couldn't load the catalogue for the requested locale + // Add empty translations to suppress some of the warnings produces by node-gettext + catalogue.addTranslations(SOURCE_LANGUAGE, domain, {}); + catalogue.setLocale(SOURCE_LANGUAGE); + return; +} + +function parseTranslation( + locale: string, + domain: string, + catalogue: Gettext, +): GetTextTranslations | undefined { + const filename = path.join(LOCALES_DIR, locale, `${domain}.po`); + let contents: string; + + try { + contents = fs.readFileSync(filename, { encoding: 'utf8' }); + } catch (error) { + if (error.code !== 'ENOENT') { + log.error(`Cannot read the gettext file "${filename}": ${error.message}`); + } + return undefined; + } + + let translations: GetTextTranslations; + try { + translations = po.parse(contents); + } catch (error) { + log.error(`Cannot parse the gettext file "${filename}": ${error.message}`); + return undefined; + } + + catalogue.addTranslations(locale, domain, translations); + + return translations; +} diff --git a/gui/src/main/logging.ts b/gui/src/main/logging.ts index fb21c54b48..7029625d3f 100644 --- a/gui/src/main/logging.ts +++ b/gui/src/main/logging.ts @@ -1,7 +1,7 @@ import { app } from 'electron'; import fs from 'fs'; import path from 'path'; -import { IpcMainEventChannel } from '../shared/ipc-event-channel'; +import { IpcMainEventChannel } from './ipc-event-channel'; import { LogLevel, ILogInput, ILogOutput } from '../shared/logging-types'; export const OLD_LOG_FILES = ['frontend-renderer.log']; diff --git a/gui/src/main/window-controller.ts b/gui/src/main/window-controller.ts index a44724d23f..69ae6acb02 100644 --- a/gui/src/main/window-controller.ts +++ b/gui/src/main/window-controller.ts @@ -1,15 +1,12 @@ import { BrowserWindow, Display, screen, Tray, WebContents } from 'electron'; -import { IpcMainEventChannel } from '../shared/ipc-event-channel'; +import { IpcMainEventChannel } from './ipc-event-channel'; +import { IWindowShapeParameters } from '../shared/ipc-types'; interface IPosition { x: number; y: number; } -export interface IWindowShapeParameters { - arrowPosition?: number; -} - interface IWindowPositioning { getPosition(window: BrowserWindow): IPosition; getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters; diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index f63e03633a..9ff1459322 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -15,14 +15,15 @@ import configureStore from './redux/store'; import userInterfaceActions from './redux/userinterface/actions'; import versionActions from './redux/version/actions'; -import { ICurrentAppVersionInfo } from '../main'; -import { loadTranslations, messages, relayLocations } from '../shared/gettext'; -import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; -import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-channel'; +import { ICurrentAppVersionInfo } from '../shared/ipc-types'; import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; +import { messages, relayLocations } from '../shared/gettext'; +import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import log, { ConsoleOutput } from '../shared/logging'; +import { IRelayListPair } from '../shared/ipc-schema'; import consumePromise from '../shared/promise'; import History from './lib/history'; +import { loadTranslations } from './lib/load-translations'; import { AccountToken, @@ -45,6 +46,8 @@ import { import { LogLevel } from '../shared/logging-types'; import IpcOutput from './lib/logging'; +const IpcRendererEventChannel = window.ipc; + interface IPreferredLocaleDescriptor { name: string; code: string; @@ -98,20 +101,6 @@ export default class AppRenderer { log.addOutput(new ConsoleOutput(LogLevel.debug)); log.addOutput(new IpcOutput(LogLevel.debug)); - IpcRendererEventChannel.locale.listen((locale) => { - // load translations for the new locale - this.loadTranslations(locale); - - // set current locale - this.setLocale(locale); - - // refresh the relay list pair with the new translations - this.propagateRelayListPairToRedux(); - - // refresh the location with the new translations - this.propagateLocationToRedux(); - }); - IpcRendererEventChannel.windowShape.listen((windowShapeParams) => { if (typeof windowShapeParams.arrowPosition === 'number') { this.reduxActions.userInterface.updateWindowArrowPosition(windowShapeParams.arrowPosition); @@ -186,9 +175,20 @@ export default class AppRenderer { // Request the initial state from the main process const initialState = IpcRendererEventChannel.state.get(); - // Load translations - this.loadTranslations(initialState.locale); + window.platform = initialState.platform; + window.runningInDevelopment = initialState.runningInDevelopment; + this.setLocale(initialState.locale); + loadTranslations( + messages, + initialState.translations.locale, + initialState.translations.messages, + ); + loadTranslations( + relayLocations, + initialState.translations.locale, + initialState.translations.relayLocations, + ); this.setAccountExpiry(initialState.accountData && initialState.accountData.expiry); this.handleAccountChange(undefined, initialState.settings.accountToken); @@ -464,8 +464,23 @@ export default class AppRenderer { ]; } - public setPreferredLocale(preferredLocale: string) { - IpcRendererEventChannel.guiSettings.setPreferredLocale(preferredLocale); + public async setPreferredLocale(preferredLocale: string): Promise<void> { + const translations = await IpcRendererEventChannel.guiSettings.setPreferredLocale( + preferredLocale, + ); + + // set current locale + this.setLocale(translations.locale); + + // load translations for new locale + loadTranslations(messages, translations.locale, translations.messages); + loadTranslations(relayLocations, translations.locale, translations.relayLocations); + + // refresh the relay list pair with the new translations + this.propagateRelayListPairToRedux(); + + // refresh the location with the new translations + this.propagateLocationToRedux(); } public getPreferredLocaleDisplayName(localeCode: string): string { @@ -479,12 +494,6 @@ export default class AppRenderer { this.loginTimer = global.setTimeout(() => this.history.resetWith('/connect'), 1000); } - private loadTranslations(locale: string) { - for (const catalogue of [messages, relayLocations]) { - loadTranslations(locale, catalogue); - } - } - private setLocale(locale: string) { this.locale = locale; this.reduxActions.userInterface.updateLocale(locale); @@ -576,7 +585,7 @@ export default class AppRenderer { } private async autoConnect() { - if (process.env.NODE_ENV === 'development') { + if (window.runningInDevelopment) { log.info('Skip autoconnect in development'); } else if (this.autoConnected) { log.info('Skip autoconnect because it was done before'); diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 6633e35746..7c97f0d844 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -429,7 +429,7 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { <Cell.Icon height={12} width={7} source="icon-chevron" /> </Cell.CellButton> - {process.platform === 'linux' && ( + {window.platform === 'linux' && ( <Cell.CellButton onClick={this.props.onViewLinuxSplitTunneling}> <Cell.Label> {messages.pgettext('advanced-settings-view', 'Split tunneling')} diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx index bc56ffcf3c..87c5b82dce 100644 --- a/gui/src/renderer/components/CustomScrollbars.tsx +++ b/gui/src/renderer/components/CustomScrollbars.tsx @@ -47,7 +47,7 @@ interface IScrollbarUpdateContext { export default class CustomScrollbars extends React.Component<IProps, IState> { public static defaultProps: IProps = { // auto-hide on macOS by default - autoHide: process.platform === 'darwin', + autoHide: window.platform === 'darwin', trackPadding: { x: 2, y: 2 }, }; diff --git a/gui/src/renderer/components/Focus.tsx b/gui/src/renderer/components/Focus.tsx index 6a2e9c7126..85c2c307e4 100644 --- a/gui/src/renderer/components/Focus.tsx +++ b/gui/src/renderer/components/Focus.tsx @@ -1,4 +1,3 @@ -import path from 'path'; import React, { useImperativeHandle, useState } from 'react'; import { useLocation } from 'react-router'; import { sprintf } from 'sprintf-js'; @@ -29,7 +28,7 @@ function Focus(props: IFocusProps, ref: React.Ref<IFocusHandle>) { ref, () => ({ resetFocus: () => { - const pageName = path.basename(location.pathname); + const pageName = location.pathname.slice(location.pathname.lastIndexOf('/') + 1); const titleElement = document.getElementsByTagName('h1')[0]; const titleContent = titleElement?.textContent ?? pageName; setTitle(titleContent); diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx index 67b9622b21..bdf7d51924 100644 --- a/gui/src/renderer/components/HeaderBar.tsx +++ b/gui/src/renderer/components/HeaderBar.tsx @@ -28,7 +28,7 @@ interface IHeaderBarContainerProps { const HeaderBarContainer = styled.header({}, (props: IHeaderBarContainerProps) => ({ padding: '12px 16px', - paddingTop: process.platform === 'darwin' && !props.unpinnedWindow ? '24px' : '12px', + paddingTop: window.platform === 'darwin' && !props.unpinnedWindow ? '24px' : '12px', backgroundColor: headerBarStyleColorMap[props.barStyle ?? HeaderBarStyle.default], })); diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx index fd6588b1fb..3be04159a2 100644 --- a/gui/src/renderer/components/ImageView.tsx +++ b/gui/src/renderer/components/ImageView.tsx @@ -1,4 +1,3 @@ -import path from 'path'; import React, { useMemo } from 'react'; import styled from 'styled-components'; @@ -40,7 +39,7 @@ const ImageMask = styled.div((props: IImageMaskProps) => { const HiddenImage = styled.img({ visibility: 'hidden' }); export default function ImageView(props: IImageViewProps) { - const url = path.isAbsolute(props.source) + const url = props.source.startsWith('data:') ? props.source : `../../assets/images/${props.source}.svg`; diff --git a/gui/src/renderer/components/NavigationBarStyles.tsx b/gui/src/renderer/components/NavigationBarStyles.tsx index 98c5184ea3..a82256b077 100644 --- a/gui/src/renderer/components/NavigationBarStyles.tsx +++ b/gui/src/renderer/components/NavigationBarStyles.tsx @@ -20,7 +20,7 @@ export const StyledNavigationItems = styled.div({ export const StyledNavigationBar = styled.nav((props: { unpinnedWindow: boolean }) => ({ flex: 0, padding: '12px', - paddingTop: process.platform === 'darwin' && !props.unpinnedWindow ? '24px' : '12px', + paddingTop: window.platform === 'darwin' && !props.unpinnedWindow ? '24px' : '12px', })); export const StyledNavigationBarWrapper = styled.div({ diff --git a/gui/src/renderer/components/PlatformWindow.tsx b/gui/src/renderer/components/PlatformWindow.tsx index 633b7aee95..83aa7b3f7f 100644 --- a/gui/src/renderer/components/PlatformWindow.tsx +++ b/gui/src/renderer/components/PlatformWindow.tsx @@ -10,7 +10,7 @@ interface IPlatformWindowProps { export default styled.div({}, (props: IPlatformWindowProps) => { let mask: string | undefined; - if (process.platform === 'darwin' && !props.unpinnedWindow) { + if (window.platform === 'darwin' && !props.unpinnedWindow) { const arrowPositionCss = props.arrowPosition !== undefined ? `${props.arrowPosition - ARROW_WIDTH * 0.5}px` : '50%'; diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx index ab2b423686..d47a7315c6 100644 --- a/gui/src/renderer/components/Preferences.tsx +++ b/gui/src/renderer/components/Preferences.tsx @@ -179,8 +179,8 @@ export default class Preferences extends React.Component<IProps> { </Cell.Footer> </AriaInputGroup> - {(process.platform === 'win32' || - (process.platform === 'darwin' && process.env.NODE_ENV === 'development')) && ( + {(window.platform === 'win32' || + (window.platform === 'darwin' && window.runningInDevelopment)) && ( <AriaInputGroup> <Cell.Container> <AriaLabel> diff --git a/gui/src/renderer/containers/SelectLanguagePage.tsx b/gui/src/renderer/containers/SelectLanguagePage.tsx index eb052fb8a6..5c073c22e1 100644 --- a/gui/src/renderer/containers/SelectLanguagePage.tsx +++ b/gui/src/renderer/containers/SelectLanguagePage.tsx @@ -11,8 +11,8 @@ const mapStateToProps = (state: IReduxState) => ({ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: RouteComponentProps & IAppContext) => { return { preferredLocalesList: props.app.getPreferredLocaleList(), - setPreferredLocale(locale: string) { - props.app.setPreferredLocale(locale); + async setPreferredLocale(locale: string) { + await props.app.setPreferredLocale(locale); props.history.goBack(); }, onClose() { diff --git a/gui/src/renderer/context.tsx b/gui/src/renderer/context.tsx index 113a29fe71..d4b20daeb1 100644 --- a/gui/src/renderer/context.tsx +++ b/gui/src/renderer/context.tsx @@ -6,7 +6,7 @@ export interface IAppContext { } export const AppContext = React.createContext<IAppContext | undefined>(undefined); -if (process.env.NODE_ENV === 'development') { +if (window.runningInDevelopment) { AppContext.displayName = 'AppContext'; } @@ -34,7 +34,7 @@ export default function withAppContext<Props>(BaseComponent: React.ComponentType ); }; - if (process.env.NODE_ENV === 'development') { + if (window.runningInDevelopment) { wrappedComponent.displayName = 'withAppContext(' + (BaseComponent.displayName || BaseComponent.name) + ')'; } diff --git a/gui/src/renderer/index.html b/gui/src/renderer/index.html index 3f2da3d633..043955b3c5 100644 --- a/gui/src/renderer/index.html +++ b/gui/src/renderer/index.html @@ -4,10 +4,11 @@ <title>Mullvad VPN</title> <link rel="stylesheet" href="../../assets/css/style.css" /> <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'"> + <meta charset="UTF-8"> </head> <body> <div id="app"></div> <script>var exports = {};</script> - <script src="./index.js"></script> + <script src="./bundle.js"></script> </body> </html> diff --git a/gui/src/renderer/lib/ipc-event-channel.ts b/gui/src/renderer/lib/ipc-event-channel.ts new file mode 100644 index 0000000000..bcb816e918 --- /dev/null +++ b/gui/src/renderer/lib/ipc-event-channel.ts @@ -0,0 +1,5 @@ +import { ipcRenderer } from 'electron'; +import { createIpcRenderer } from '../../shared/ipc-helpers'; +import { ipcSchema } from '../../shared/ipc-schema'; + +export const IpcRendererEventChannel = createIpcRenderer(ipcSchema, ipcRenderer); diff --git a/gui/src/renderer/lib/load-translations.ts b/gui/src/renderer/lib/load-translations.ts new file mode 100644 index 0000000000..43a483de25 --- /dev/null +++ b/gui/src/renderer/lib/load-translations.ts @@ -0,0 +1,22 @@ +import { GetTextTranslations } from 'gettext-parser'; +import Gettext from 'node-gettext'; +import log from '../../shared/logging'; + +const SOURCE_LANGUAGE = 'en'; + +export function loadTranslations( + catalogue: Gettext, + locale: string, + translations?: GetTextTranslations, +) { + if (translations) { + catalogue.addTranslations(locale, catalogue.domain, translations); + catalogue.setLocale(locale); + log.info(`Loaded translations ${locale}/${catalogue.domain}`); + } else { + // Reset the locale to source language if we couldn't load the catalogue for the requested locale + // Add empty translations to suppress some of the warnings produces by node-gettext + catalogue.addTranslations(SOURCE_LANGUAGE, catalogue.domain, {}); + catalogue.setLocale(SOURCE_LANGUAGE); + } +} diff --git a/gui/src/renderer/lib/logging.ts b/gui/src/renderer/lib/logging.ts index 433ba20f8f..7c8dc9e542 100644 --- a/gui/src/renderer/lib/logging.ts +++ b/gui/src/renderer/lib/logging.ts @@ -1,10 +1,9 @@ -import { IpcRendererEventChannel } from '../../shared/ipc-event-channel'; import { ILogOutput, LogLevel } from '../../shared/logging-types'; export default class IpcOutput implements ILogOutput { constructor(public level: LogLevel) {} public write(level: LogLevel, message: string) { - IpcRendererEventChannel.logging.log({ level: level, message }); + window.ipc.logging.log({ level: level, message }); } } diff --git a/gui/src/renderer/preload.ts b/gui/src/renderer/preload.ts new file mode 100644 index 0000000000..5c44b71eeb --- /dev/null +++ b/gui/src/renderer/preload.ts @@ -0,0 +1,4 @@ +import { contextBridge } from 'electron'; +import { IpcRendererEventChannel } from './lib/ipc-event-channel'; + +contextBridge.exposeInMainWorld('ipc', IpcRendererEventChannel); diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 59b4c32b46..592caab687 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -148,7 +148,7 @@ const initialState: ISettingsReduxState = { autoConnect: true, monochromaticIcon: false, startMinimized: false, - unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin', + unpinnedWindow: window.platform !== 'win32' && window.platform !== 'darwin', }, relaySettings: { normal: { diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts index 49f864d18b..92d85e4dbe 100644 --- a/gui/src/renderer/redux/store.ts +++ b/gui/src/renderer/redux/store.ts @@ -54,7 +54,7 @@ export default function configureStore(initialState?: IReduxState) { const composeEnhancers: typeof compose = (() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const reduxCompose = window && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; - if (process.env.NODE_ENV === 'development' && reduxCompose) { + if (window.runningInDevelopment && reduxCompose) { return reduxCompose({ actionCreators }); } return compose; diff --git a/gui/src/shared/gettext.ts b/gui/src/shared/gettext.ts index 3283269c70..0416e8b1ce 100644 --- a/gui/src/shared/gettext.ts +++ b/gui/src/shared/gettext.ts @@ -1,67 +1,8 @@ -import fs from 'fs'; -import { po } from 'gettext-parser'; import Gettext from 'node-gettext'; -import path from 'path'; import { LocalizationContexts } from './localization-contexts'; import log from './logging'; const SOURCE_LANGUAGE = 'en'; -const LOCALES_DIR = path.resolve(__dirname, '../../locales'); - -export function loadTranslations(currentLocale: string, catalogue: Gettext) { - // First look for exact match of the current locale - const preferredLocales = []; - - if (currentLocale !== SOURCE_LANGUAGE) { - preferredLocales.push(currentLocale); - } - - // In case of region bound locale like en-US, fallback to en. - const language = Gettext.getLanguageCode(currentLocale); - if (currentLocale !== language) { - preferredLocales.push(language); - } - - const domain = catalogue.domain; - for (const locale of preferredLocales) { - if (parseTranslation(locale, domain, catalogue)) { - log.info(`Loaded translations ${locale}/${domain}`); - catalogue.setLocale(locale); - return; - } - } - - // Reset the locale to source language if we couldn't load the catalogue for the requested locale - // Add empty translations to suppress some of the warnings produces by node-gettext - catalogue.addTranslations(SOURCE_LANGUAGE, domain, {}); - catalogue.setLocale(SOURCE_LANGUAGE); -} - -function parseTranslation(locale: string, domain: string, catalogue: Gettext): boolean { - const filename = path.join(LOCALES_DIR, locale, `${domain}.po`); - let contents: string; - - try { - contents = fs.readFileSync(filename, { encoding: 'utf8' }); - } catch (error) { - if (error.code !== 'ENOENT') { - log.error(`Cannot read the gettext file "${filename}": ${error.message}`); - } - return false; - } - - let translations: ReturnType<typeof po.parse>; - try { - translations = po.parse(contents); - } catch (error) { - log.error(`Cannot parse the gettext file "${filename}": ${error.message}`); - return false; - } - - catalogue.addTranslations(locale, domain, translations); - - return true; -} function setErrorHandler(catalogue: Gettext) { catalogue.on('error', (error) => { diff --git a/gui/src/shared/ipc-helpers.ts b/gui/src/shared/ipc-helpers.ts index d1a72a2df6..59eee46459 100644 --- a/gui/src/shared/ipc-helpers.ts +++ b/gui/src/shared/ipc-helpers.ts @@ -1,4 +1,4 @@ -import { ipcMain, ipcRenderer, WebContents } from 'electron'; +import { IpcMain as EIpcMain, IpcRenderer as EIpcRenderer, WebContents } from 'electron'; import { capitalize } from './string-helpers'; import log from './logging'; @@ -7,26 +7,21 @@ type Sender<T, R> = (arg: T) => R; type Notifier<T> = (webContents: WebContents, arg: T) => void; type Listener<T> = (callback: (arg: T) => void) => void; -interface IpcCall<T, R> { - direction: 'renderer-to-main' | 'main-to-renderer'; - send: (event: string) => Notifier<T> | Sender<T, R>; - receive: (event: string) => Listener<T> | Handler<T, R>; -} - -interface MainToRenderer<T> extends IpcCall<T, never> { +interface MainToRenderer<T> { direction: 'main-to-renderer'; - send: (event: string) => Notifier<T>; - receive: (event: string) => Listener<T>; + send: (event: string, ipcMain: EIpcMain) => Notifier<T>; + receive: (event: string, ipcRenderer: EIpcRenderer) => Listener<T>; } -interface RendererToMain<T, R> extends IpcCall<T, R> { +interface RendererToMain<T, R> { direction: 'renderer-to-main'; - send: (event: string) => Sender<T, R>; - receive: (event: string) => Handler<T, R>; + send: (event: string, ipcRenderer: EIpcRenderer) => Sender<T, R>; + receive: (event: string, ipcMain: EIpcMain) => Handler<T, R>; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyIpcCall = IpcCall<any, any>; +type AnyIpcCall = MainToRenderer<any> | RendererToMain<any, any>; + type Schema = Record<string, Record<string, AnyIpcCall>>; // Renames all IPC calls, e.g. `callName` to either `notifyCallName` or `handleCallName` depending @@ -66,22 +61,31 @@ type IpcRenderer<S extends Schema> = { }; // Preforms the transformation of the main event channel in accordance with the above types. -export function createIpcMain<S extends Schema>(ipc: S): IpcMain<S> { - return createIpc(ipc, (event, key, spec) => { +export function createIpcMain<S extends Schema>(schema: S, ipcMain: EIpcMain): IpcMain<S> { + return createIpc(schema, (event, key, spec) => { const capitalizedKey = capitalize(key); const newKey = spec.direction === 'main-to-renderer' ? `notify${capitalizedKey}` : `handle${capitalizedKey}`; - const newValue = spec.direction === 'main-to-renderer' ? spec.send(event) : spec.receive(event); + const newValue = + spec.direction === 'main-to-renderer' + ? spec.send(event, ipcMain) + : spec.receive(event, ipcMain); return [newKey, newValue]; }); } // Preforms the transformation of the renderer event channel in accordance with the above types. -export function createIpcRenderer<S extends Schema>(ipc: S): IpcRenderer<S> { - return createIpc(ipc, (event, key, spec) => { +export function createIpcRenderer<S extends Schema>( + schema: S, + ipcRenderer: EIpcRenderer, +): IpcRenderer<S> { + return createIpc(schema, (event, key, spec) => { const newKey = spec.direction === 'main-to-renderer' ? `listen${capitalize(key)}` : key; - const newValue = spec.direction === 'main-to-renderer' ? spec.receive(event) : spec.send(event); + const newValue = + spec.direction === 'main-to-renderer' + ? spec.receive(event, ipcRenderer) + : spec.send(event, ipcRenderer); return [newKey, newValue]; }); @@ -105,8 +109,8 @@ function createIpc<S extends Schema, T, R extends IpcMain<S> | IpcRenderer<S>>( export function send<T>(): RendererToMain<T, void> { return { direction: 'renderer-to-main', - send: (event: string) => (newValue: T) => ipcRenderer.send(event, newValue), - receive: (event: string) => (handlerFn: (value: T) => void) => { + send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.send(event, newValue), + receive: (event, ipcMain) => (handlerFn: (value: T) => void) => { ipcMain.on(event, (_event, newValue: T) => { handlerFn(newValue); }); @@ -118,8 +122,8 @@ export function send<T>(): RendererToMain<T, void> { export function invokeSync<T, R>(): RendererToMain<T, R> { return { direction: 'renderer-to-main', - send: (event: string) => (newValue: T) => ipcRenderer.sendSync(event, newValue), - receive: (event: string) => (handlerFn: (value: T) => R) => { + send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.sendSync(event, newValue), + receive: (event, ipcMain) => (handlerFn: (value: T) => R) => { ipcMain.on(event, (ipcEvent, newValue: T) => { ipcEvent.returnValue = handlerFn(newValue); }); @@ -141,13 +145,13 @@ export function notifyRenderer<T>(): MainToRenderer<T> { return { direction: 'main-to-renderer', send: notifyRendererImpl, - receive: (event: string) => (fn: (value: T) => void) => { + receive: (event, ipcRenderer) => (fn: (value: T) => void) => { ipcRenderer.on(event, (_event, newState: T) => fn(newState)); }, }; } -function notifyRendererImpl<T>(event: string): Notifier<T> { +function notifyRendererImpl<T>(event: string, _ipcMain: EIpcMain): Notifier<T> { return (webContents: WebContents, value: T) => { if (webContents.isDestroyed()) { log.error(`sender(${event}): webContents is already destroyed!`); @@ -159,7 +163,7 @@ function notifyRendererImpl<T>(event: string): Notifier<T> { type RequestResult<T> = { type: 'success'; value: T } | { type: 'error'; message: string }; -function invokeImpl<T, R>(event: string): Sender<T, Promise<R>> { +function invokeImpl<T, R>(event: string, ipcRenderer: EIpcRenderer): Sender<T, Promise<R>> { return async (arg: T): Promise<R> => { const result: RequestResult<R> = await ipcRenderer.invoke(event, arg); switch (result.type) { @@ -171,7 +175,7 @@ function invokeImpl<T, R>(event: string): Sender<T, Promise<R>> { }; } -function handle<T, R>(event: string): Handler<T, Promise<R>> { +function handle<T, R>(event: string, ipcMain: EIpcMain): Handler<T, Promise<R>> { return (fn: (arg: T) => Promise<R>) => { ipcMain.handle(event, async (_ipcEvent, arg: T) => { try { diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-schema.ts index 19c11f85ab..a31745c01f 100644 --- a/gui/src/shared/ipc-event-channel.ts +++ b/gui/src/shared/ipc-schema.ts @@ -1,6 +1,5 @@ -import { ICurrentAppVersionInfo } from '../main/index'; -import { IWindowShapeParameters } from '../main/window-controller'; -import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; +import { GetTextTranslations } from 'gettext-parser'; +import { ILinuxSplitTunnelingApplication } from './application-types'; import { AccountToken, BridgeSettings, @@ -18,20 +17,20 @@ import { VoucherResponse, } from './daemon-rpc-types'; import { IGuiSettingsState } from './gui-settings-state'; -import { - createIpcMain, - createIpcRenderer, - invoke, - invokeSync, - notifyRenderer, - send, -} from './ipc-helpers'; import { LogLevel } from './logging-types'; interface ILogEntry { level: LogLevel; message: string; } +import { invoke, invokeSync, notifyRenderer, send } from './ipc-helpers'; +import { ICurrentAppVersionInfo, IWindowShapeParameters } from './ipc-types'; + +export interface ITranslations { + locale: string; + messages?: GetTextTranslations; + relayLocations?: GetTextTranslations; +} export interface IRelayListPair { relays: IRelayList; @@ -52,6 +51,9 @@ export interface IAppStateSnapshot { upgradeVersion: IAppVersionInfo; guiSettings: IGuiSettingsState; wireguardPublicKey?: IWireguardPublicKey; + translations: ITranslations; + platform: NodeJS.Platform; + runningInDevelopment: boolean; } // The different types of requests are: @@ -93,13 +95,10 @@ export interface IAppStateSnapshot { // listenFourth: (fn: (arg: boolean) => void) => void, // }, // } -const ipc = { +export const ipcSchema = { state: { get: invokeSync<void, IAppStateSnapshot>(), }, - locale: { - '': notifyRenderer<string>(), - }, windowShape: { '': notifyRenderer<IWindowShapeParameters>(), }, @@ -155,7 +154,7 @@ const ipc = { setAutoConnect: send<boolean>(), setStartMinimized: send<boolean>(), setMonochromaticIcon: send<boolean>(), - setPreferredLocale: send<string>(), + setPreferredLocale: invoke<string, ITranslations>(), setUnpinnedWindow: send<boolean>(), }, account: { @@ -192,6 +191,3 @@ const ipc = { log: send<ILogEntry>(), }, }; - -export const IpcMainEventChannel = createIpcMain(ipc); -export const IpcRendererEventChannel = createIpcRenderer(ipc); diff --git a/gui/src/shared/ipc-types.ts b/gui/src/shared/ipc-types.ts new file mode 100644 index 0000000000..7781551ed5 --- /dev/null +++ b/gui/src/shared/ipc-types.ts @@ -0,0 +1,10 @@ +export interface ICurrentAppVersionInfo { + gui: string; + daemon: string; + isConsistent: boolean; + isBeta: boolean; +} + +export interface IWindowShapeParameters { + arrowPosition?: number; +} diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts index d580bf1f1e..c5f15482dc 100644 --- a/gui/src/shared/notifications/error.ts +++ b/gui/src/shared/notifications/error.ts @@ -45,7 +45,7 @@ export class ErrorNotificationProvider function getMessage(errorDetails: IErrorState, accountExpiry?: string): string { if (errorDetails.blockFailure) { if (errorDetails.cause.reason === 'set_firewall_policy_error') { - switch (process.platform) { + switch (process.platform ?? window.platform) { case 'win32': return messages.pgettext( 'notifications', @@ -86,7 +86,7 @@ function getMessage(errorDetails: IErrorState, accountExpiry?: string): string { 'Could not configure IPv6. Disable it in the app or enable it on your device.', ); case 'set_firewall_policy_error': - switch (process.platform) { + switch (process.platform ?? window.platform) { case 'win32': return messages.pgettext( 'notifications', diff --git a/gui/src/shared/notifications/no-valid-key.ts b/gui/src/shared/notifications/no-valid-key.ts index d1b90095e2..8da145a1f0 100644 --- a/gui/src/shared/notifications/no-valid-key.ts +++ b/gui/src/shared/notifications/no-valid-key.ts @@ -14,7 +14,7 @@ export class NoValidKeyNotificationProvider implements InAppNotificationProvider public mayDisplay() { const usingWireGuard = this.context.tunnelProtocol === 'wireguard' || - (this.context.tunnelProtocol === 'any' && process.platform !== 'win32'); + (this.context.tunnelProtocol === 'any' && (process.platform ?? window.platform) !== 'win32'); const keyInvalid = this.context.wireGuardKey.type === 'key-not-set' || this.context.wireGuardKey.type === 'too-many-keys' || |
