import { ConnectedRouter, push as pushHistory, replace as replaceHistory, } from 'connected-react-router'; import { ipcRenderer, webFrame } from 'electron'; import log from 'electron-log'; import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Provider } from 'react-redux'; import { bindActionCreators } from 'redux'; import { InvalidAccountError } from '../main/errors'; import ErrorBoundary from './components/ErrorBoundary'; import AppRoutes from './routes'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; import configureStore from './redux/store'; import userInterfaceActions from './redux/userinterface/actions'; import versionActions from './redux/version/actions'; import { IAppUpgradeInfo, ICurrentAppVersionInfo } from '../main'; import { IWindowShapeParameters } from '../main/window-controller'; import { cities, countries, loadTranslations, messages, relayLocations } from '../shared/gettext'; import { IGuiSettingsState } from '../shared/gui-settings-state'; import { IpcRendererEventChannel } from '../shared/ipc-event-channel'; import AccountDataCache, { AccountFetchRetryAction } from './lib/account-data-cache'; import AccountExpiry from './lib/account-expiry'; import { AccountToken, ILocation, IRelayList, ISettings, RelaySettings, RelaySettingsUpdate, TunnelStateTransition, } from '../shared/daemon-rpc-types'; type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error }; export default class AppRenderer { private memoryHistory = createMemoryHistory(); private reduxStore = configureStore(this.memoryHistory); private reduxActions = { account: bindActionCreators(accountActions, this.reduxStore.dispatch), connection: bindActionCreators(connectionActions, this.reduxStore.dispatch), settings: bindActionCreators(settingsActions, this.reduxStore.dispatch), version: bindActionCreators(versionActions, this.reduxStore.dispatch), userInterface: bindActionCreators(userInterfaceActions, this.reduxStore.dispatch), history: bindActionCreators( { push: pushHistory, replace: replaceHistory, }, this.reduxStore.dispatch, ), }; private accountDataCache = new AccountDataCache( (accountToken) => { return IpcRendererEventChannel.account.getData(accountToken); }, (accountData) => { this.setAccountExpiry(accountData && accountData.expiry); }, ); private locale: string; private tunnelState: TunnelStateTransition; private settings: ISettings; private guiSettings: IGuiSettingsState; private accountExpiry?: AccountExpiry; private connectedToDaemon = false; private autoConnected = false; private doingLogin = false; private loginTimer?: NodeJS.Timeout; constructor() { ipcRenderer.on( 'update-window-shape', (_event: Electron.Event, shapeParams: IWindowShapeParameters) => { if (typeof shapeParams.arrowPosition === 'number') { this.reduxActions.userInterface.updateWindowArrowPosition(shapeParams.arrowPosition); } }, ); ipcRenderer.on('window-shown', () => { if (this.connectedToDaemon) { this.updateAccountExpiry(); } }); IpcRendererEventChannel.daemonConnected.listen(() => { this.onDaemonConnected(); }); IpcRendererEventChannel.daemonDisconnected.listen((errorMessage?: string) => { this.onDaemonDisconnected(errorMessage ? new Error(errorMessage) : undefined); }); IpcRendererEventChannel.accountHistory.listen((newAccountHistory: AccountToken[]) => { this.setAccountHistory(newAccountHistory); }); IpcRendererEventChannel.tunnel.listen((newState: TunnelStateTransition) => { this.setTunnelState(newState); this.updateBlockedState(newState, this.settings.blockWhenDisconnected); if (this.accountExpiry) { this.detectStaleAccountExpiry(newState, this.accountExpiry); } }); IpcRendererEventChannel.settings.listen((newSettings: ISettings) => { const oldSettings = this.settings; this.setSettings(newSettings); this.handleAccountChange(oldSettings.accountToken, newSettings.accountToken); this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected); }); IpcRendererEventChannel.location.listen((newLocation: ILocation) => { this.setLocation(newLocation); }); IpcRendererEventChannel.relays.listen((newRelays: IRelayList) => { this.setRelays(newRelays); }); IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => { this.setCurrentVersion(currentVersion); }); IpcRendererEventChannel.upgradeVersion.listen((upgradeVersion: IAppUpgradeInfo) => { this.setUpgradeVersion(upgradeVersion); }); IpcRendererEventChannel.guiSettings.listen((guiSettings: IGuiSettingsState) => { this.setGuiSettings(guiSettings); }); IpcRendererEventChannel.autoStart.listen((autoStart: boolean) => { this.storeAutoStart(autoStart); }); // Request the initial state from the main process const initialState = IpcRendererEventChannel.state.get(); this.locale = initialState.locale; this.tunnelState = initialState.tunnelState; this.settings = initialState.settings; this.guiSettings = initialState.guiSettings; this.setAccountHistory(initialState.accountHistory); this.setSettings(initialState.settings); this.setTunnelState(initialState.tunnelState); this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected); if (initialState.location) { this.setLocation(initialState.location); } this.setRelays(initialState.relays); this.setCurrentVersion(initialState.currentVersion); this.setUpgradeVersion(initialState.upgradeVersion); this.setGuiSettings(initialState.guiSettings); this.storeAutoStart(initialState.autoStart); if (initialState.isConnected) { this.onDaemonConnected(); } // disable pinch to zoom webFrame.setVisualZoomLevelLimits(1, 1); // Load translations for (const catalogue of [messages, countries, cities, relayLocations]) { loadTranslations(this.locale, catalogue); } } public renderView() { return ( ); } public async login(accountToken: AccountToken) { const actions = this.reduxActions; actions.account.startLogin(accountToken); log.info('Logging in'); this.doingLogin = true; try { const verification = await this.verifyAccount(accountToken); if (verification.status === 'deferred') { log.warn(`Failed to get account data, logging in anyway: ${verification.error.message}`); } await IpcRendererEventChannel.account.set(accountToken); // Redirect the user after some time to allow for the 'Logged in' screen to be visible this.loginTimer = global.setTimeout(async () => { this.memoryHistory.replace('/connect'); try { log.info('Auto-connecting the tunnel'); await this.connectTunnel(); } catch (error) { log.error(`Failed to auto-connect the tunnel: ${error.message}`); } }, 1000); } catch (error) { log.error('Failed to log in,', error.message); actions.account.loginFailed(error); } } public verifyAccount(accountToken: AccountToken): Promise { return new Promise((resolve, reject) => { this.accountDataCache.invalidate(); this.accountDataCache.fetch(accountToken, { onFinish: () => resolve({ status: 'verified' }), onError: (error): AccountFetchRetryAction => { if (error instanceof InvalidAccountError) { reject(error); return AccountFetchRetryAction.stop; } else { resolve({ status: 'deferred', error }); return AccountFetchRetryAction.retry; } }, }); }); } public async logout() { try { await IpcRendererEventChannel.account.unset(); } catch (e) { log.info('Failed to logout: ', e.message); } } public async connectTunnel(): Promise { const state = this.tunnelState.state; // connect only if tunnel is disconnected or blocked. if (state === 'disconnecting' || state === 'disconnected' || state === 'blocked') { // switch to the connecting state ahead of time to make the app look more responsive this.reduxActions.connection.connecting(); return IpcRendererEventChannel.tunnel.connect(); } } public disconnectTunnel(): Promise { return IpcRendererEventChannel.tunnel.disconnect(); } public updateRelaySettings(relaySettings: RelaySettingsUpdate) { return IpcRendererEventChannel.settings.updateRelaySettings(relaySettings); } public updateAccountExpiry() { if (this.settings.accountToken) { this.accountDataCache.fetch(this.settings.accountToken); } } public async removeAccountFromHistory(accountToken: AccountToken): Promise { return IpcRendererEventChannel.accountHistory.removeItem(accountToken); } public async setAllowLan(allowLan: boolean) { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setAllowLan(allowLan); actions.settings.updateAllowLan(allowLan); } public async setEnableIpv6(enableIpv6: boolean) { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setEnableIpv6(enableIpv6); actions.settings.updateEnableIpv6(enableIpv6); } public async setBlockWhenDisconnected(blockWhenDisconnected: boolean) { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setBlockWhenDisconnected(blockWhenDisconnected); actions.settings.updateBlockWhenDisconnected(blockWhenDisconnected); } public async setOpenVpnMssfix(mssfix?: number) { const actions = this.reduxActions; actions.settings.updateOpenVpnMssfix(mssfix); await IpcRendererEventChannel.settings.setOpenVpnMssfix(mssfix); } public async setAutoConnect(autoConnect: boolean) { return IpcRendererEventChannel.guiSettings.setAutoConnect(autoConnect); } public async setAutoStart(autoStart: boolean): Promise { this.storeAutoStart(autoStart); return IpcRendererEventChannel.autoStart.set(autoStart); } public setStartMinimized(startMinimized: boolean) { IpcRendererEventChannel.guiSettings.setStartMinimized(startMinimized); } public setMonochromaticIcon(monochromaticIcon: boolean) { IpcRendererEventChannel.guiSettings.setMonochromaticIcon(monochromaticIcon); } private setRelaySettings(relaySettings: RelaySettings) { const actions = this.reduxActions; if ('normal' in relaySettings) { const normal = relaySettings.normal; const tunnel = normal.tunnel; const location = normal.location; const relayLocation = location === 'any' ? 'any' : location.only; if (tunnel === 'any') { actions.settings.updateRelay({ normal: { location: relayLocation, port: 'any', protocol: 'any', }, }); } else { const constraints = tunnel.only; if ('openvpn' in constraints) { const { port, protocol } = constraints.openvpn; actions.settings.updateRelay({ normal: { location: relayLocation, port: port === 'any' ? port : port.only, protocol: protocol === 'any' ? protocol : protocol.only, }, }); } else if ('wireguard' in constraints) { const { port } = constraints.wireguard; actions.settings.updateRelay({ normal: { location: relayLocation, port: port === 'any' ? port : port.only, protocol: 'udp', }, }); } } } else if ('customTunnelEndpoint' in relaySettings) { const customTunnelEndpoint = relaySettings.customTunnelEndpoint; const config = customTunnelEndpoint.config; if ('openvpn' in config) { actions.settings.updateRelay({ customTunnelEndpoint: { host: customTunnelEndpoint.host, port: config.openvpn.endpoint.port, protocol: config.openvpn.endpoint.protocol, }, }); } else if ('wireguard' in config) { // TODO: handle wireguard } } } private async onDaemonConnected() { this.connectedToDaemon = true; if (this.settings.accountToken) { this.memoryHistory.replace('/connect'); // try to autoconnect the tunnel await this.autoConnect(); } else { this.memoryHistory.replace('/login'); // show window when account is not set ipcRenderer.send('show-window'); } } private onDaemonDisconnected(error?: Error) { const wasConnected = this.connectedToDaemon; this.connectedToDaemon = false; if (error && wasConnected) { this.memoryHistory.replace('/'); } } private async autoConnect() { if (process.env.NODE_ENV === 'development') { log.info('Skip autoconnect in development'); } else if (this.autoConnected) { log.info('Skip autoconnect because it was done before'); } else if (this.settings.accountToken) { if (this.guiSettings.autoConnect) { try { log.info('Autoconnect the tunnel'); await this.connectTunnel(); this.autoConnected = true; } catch (error) { log.error(`Failed to autoconnect the tunnel: ${error.message}`); } } else { log.info('Skip autoconnect because GUI setting is disabled'); } } else { log.info('Skip autoconnect because account token is not set'); } } private setAccountHistory(accountHistory: AccountToken[]) { this.reduxActions.account.updateAccountHistory(accountHistory); } private setTunnelState(tunnelState: TunnelStateTransition) { const actions = this.reduxActions; log.debug(`Tunnel state: ${tunnelState.state}`); this.tunnelState = tunnelState; switch (tunnelState.state) { case 'connecting': actions.connection.connecting(tunnelState.details); break; case 'connected': actions.connection.connected(tunnelState.details); break; case 'disconnecting': actions.connection.disconnecting(tunnelState.details); break; case 'disconnected': actions.connection.disconnected(); break; case 'blocked': actions.connection.blocked(tunnelState.details); break; } } private setSettings(newSettings: ISettings) { this.settings = newSettings; const reduxSettings = this.reduxActions.settings; const reduxAccount = this.reduxActions.account; reduxSettings.updateAllowLan(newSettings.allowLan); reduxSettings.updateEnableIpv6(newSettings.tunnelOptions.generic.enableIpv6); reduxSettings.updateBlockWhenDisconnected(newSettings.blockWhenDisconnected); reduxSettings.updateOpenVpnMssfix(newSettings.tunnelOptions.openvpn.mssfix); this.setRelaySettings(newSettings.relaySettings); if (newSettings.accountToken) { reduxAccount.updateAccountToken(newSettings.accountToken); reduxAccount.loggedIn(); } else { reduxAccount.loggedOut(); } } private updateBlockedState(tunnelState: TunnelStateTransition, blockWhenDisconnected: boolean) { const actions = this.reduxActions.connection; switch (tunnelState.state) { case 'connecting': actions.updateBlockState(true); break; case 'connected': actions.updateBlockState(false); break; case 'disconnected': actions.updateBlockState(blockWhenDisconnected); break; case 'disconnecting': actions.updateBlockState(true); break; case 'blocked': actions.updateBlockState(tunnelState.details.reason !== 'set_firewall_policy_error'); break; } } private handleAccountChange(oldAccount?: string, newAccount?: string) { if (oldAccount && !newAccount) { this.accountDataCache.invalidate(); if (this.loginTimer) { clearTimeout(this.loginTimer); } this.memoryHistory.replace('/login'); } else if (!oldAccount && newAccount) { this.accountDataCache.fetch(newAccount); if (!this.doingLogin) { this.memoryHistory.replace('/connect'); } } else if (oldAccount && newAccount && oldAccount !== newAccount) { this.accountDataCache.fetch(newAccount); } this.doingLogin = false; } private setLocation(location: ILocation) { this.reduxActions.connection.newLocation(location); } private setRelays(relayList: IRelayList) { const locations = relayList.countries .map((country) => ({ name: country.name, code: country.code, hasActiveRelays: country.cities.some((city) => city.relays.length > 0), cities: country.cities .map((city) => ({ name: city.name, code: city.code, latitude: city.latitude, longitude: city.longitude, hasActiveRelays: city.relays.length > 0, relays: city.relays, })) .sort((cityA, cityB) => cityA.name.localeCompare(cityB.name)), })) .sort((countryA, countryB) => countryA.name.localeCompare(countryB.name)); this.reduxActions.settings.updateRelayLocations(locations); } private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) { this.reduxActions.version.updateVersion(versionInfo.gui, versionInfo.isConsistent); } private setUpgradeVersion(upgradeVersion: IAppUpgradeInfo) { this.reduxActions.version.updateLatest(upgradeVersion); } private setGuiSettings(guiSettings: IGuiSettingsState) { this.guiSettings = guiSettings; this.reduxActions.settings.updateGuiSettings(guiSettings); } private setAccountExpiry(expiry?: string) { this.accountExpiry = expiry ? new AccountExpiry(expiry, this.locale) : undefined; this.reduxActions.account.updateAccountExpiry(expiry); } private detectStaleAccountExpiry( tunnelState: TunnelStateTransition, accountExpiry: AccountExpiry, ) { // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. if (tunnelState.state === 'connected' && accountExpiry.hasExpired()) { log.info('Detected the stale account expiry.'); this.accountDataCache.invalidate(); } } private storeAutoStart(autoStart: boolean) { this.reduxActions.settings.updateAutoStart(autoStart); } }