import * as React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router';
import { bindActionCreators } from 'redux';
import ErrorBoundary from './components/ErrorBoundary';
import { AppContext } from './context';
import AppRoutes from './routes';
import accountActions from './redux/account/actions';
import connectionActions from './redux/connection/actions';
import settingsActions from './redux/settings/actions';
import { IRelayLocationRedux, IWgKey } from './redux/settings/reducers';
import configureStore from './redux/store';
import userInterfaceActions from './redux/userinterface/actions';
import versionActions from './redux/version/actions';
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, LaunchApplicationResult } from '../shared/ipc-schema';
import consumePromise from '../shared/promise';
import { Scheduler } from '../shared/scheduler';
import History from './lib/history';
import { loadTranslations } from './lib/load-translations';
import {
AccountToken,
BridgeSettings,
BridgeState,
IAccountData,
IAppVersionInfo,
IDnsOptions,
ILocation,
IRelayList,
ISettings,
IWireguardPublicKey,
KeygenEvent,
liftConstraint,
RelaySettings,
RelaySettingsUpdate,
TunnelState,
VoucherResponse,
} from '../shared/daemon-rpc-types';
import { LogLevel } from '../shared/logging-types';
import IpcOutput from './lib/logging';
const IpcRendererEventChannel = window.ipc;
interface IPreferredLocaleDescriptor {
name: string;
code: string;
}
const SUPPORTED_LOCALE_LIST = [
{ name: 'Dansk', code: 'da' },
{ name: 'Deutsch', code: 'de' },
{ name: 'English', code: 'en' },
{ name: 'Español', code: 'es' },
{ name: 'Suomi', code: 'fi' },
{ name: 'Français', code: 'fr' },
{ name: 'Italiano', code: 'it' },
{ name: '日本語', code: 'ja' },
{ name: '한국어', code: 'ko' },
{ name: 'မြန်မာဘာသာ', code: 'my' },
{ name: 'Nederlands', code: 'nl' },
{ name: 'Norsk', code: 'nb' },
{ name: 'Język polski', code: 'pl' },
{ name: 'Português', code: 'pt' },
{ name: 'Русский', code: 'ru' },
{ name: 'Svenska', code: 'sv' },
{ name: 'ภาษาไทย', code: 'th' },
{ name: 'Türkçe', code: 'tr' },
{ name: '简体中文', code: 'zh-CN' },
{ name: '繁體中文', code: 'zh-TW' },
];
export default class AppRenderer {
private history = new History('/');
private reduxStore = configureStore();
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),
};
private locale = 'en';
private location?: ILocation;
private relayListPair!: IRelayListPair;
private tunnelState!: TunnelState;
private settings!: ISettings;
private guiSettings!: IGuiSettingsState;
private autoConnected = false;
private doingLogin = false;
private loginScheduler = new Scheduler();
private connectedToDaemon = false;
constructor() {
log.addOutput(new ConsoleOutput(LogLevel.debug));
log.addOutput(new IpcOutput(LogLevel.debug));
IpcRendererEventChannel.windowShape.listen((windowShapeParams) => {
if (typeof windowShapeParams.arrowPosition === 'number') {
this.reduxActions.userInterface.updateWindowArrowPosition(windowShapeParams.arrowPosition);
}
});
IpcRendererEventChannel.daemon.listenConnected(() => {
consumePromise(this.onDaemonConnected());
});
IpcRendererEventChannel.daemon.listenDisconnected(() => {
this.onDaemonDisconnected();
});
IpcRendererEventChannel.account.listen((newAccountData?: IAccountData) => {
this.setAccountExpiry(newAccountData && newAccountData.expiry);
});
IpcRendererEventChannel.accountHistory.listen((newAccountHistory: AccountToken[]) => {
this.setAccountHistory(newAccountHistory);
});
IpcRendererEventChannel.tunnel.listen((newState: TunnelState) => {
this.setTunnelState(newState);
this.updateBlockedState(newState, this.settings.blockWhenDisconnected);
});
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((relayListPair: IRelayListPair) => {
this.setRelayListPair(relayListPair);
});
IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
this.setCurrentVersion(currentVersion);
});
IpcRendererEventChannel.upgradeVersion.listen((upgradeVersion: IAppVersionInfo) => {
this.setUpgradeVersion(upgradeVersion);
});
IpcRendererEventChannel.guiSettings.listen((guiSettings: IGuiSettingsState) => {
this.setGuiSettings(guiSettings);
});
IpcRendererEventChannel.autoStart.listen((autoStart: boolean) => {
this.storeAutoStart(autoStart);
});
IpcRendererEventChannel.wireguardKeys.listenPublicKey((publicKey?: IWireguardPublicKey) => {
this.setWireguardPublicKey(publicKey);
});
IpcRendererEventChannel.wireguardKeys.listenKeygenEvent((event: KeygenEvent) => {
this.reduxActions.settings.setWireguardKeygenEvent(event);
});
IpcRendererEventChannel.windowFocus.listen((focus: boolean) => {
this.reduxActions.userInterface.setWindowFocused(focus);
});
// Request the initial state from the main process
const initialState = IpcRendererEventChannel.state.get();
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);
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.setRelayListPair(initialState.relayListPair);
this.setCurrentVersion(initialState.currentVersion);
this.setUpgradeVersion(initialState.upgradeVersion);
this.setGuiSettings(initialState.guiSettings);
this.storeAutoStart(initialState.autoStart);
this.setWireguardPublicKey(initialState.wireguardPublicKey);
if (initialState.isConnected) {
consumePromise(this.onDaemonConnected());
}
}
public renderView() {
return (
);
}
public async login(accountToken: AccountToken) {
const actions = this.reduxActions;
actions.account.startLogin(accountToken);
log.info('Logging in');
this.doingLogin = true;
try {
await IpcRendererEventChannel.account.login(accountToken);
actions.account.updateAccountToken(accountToken);
actions.account.loggedIn();
this.redirectToConnect();
} catch (error) {
actions.account.loginFailed(error);
}
}
public async logout() {
try {
await IpcRendererEventChannel.account.logout();
} catch (e) {
log.info('Failed to logout: ', e.message);
}
}
public async createNewAccount() {
log.info('Creating account');
const actions = this.reduxActions;
actions.account.startCreateAccount();
this.doingLogin = true;
try {
const accountToken = await IpcRendererEventChannel.account.create();
const accountExpiry = new Date().toISOString();
actions.account.accountCreated(accountToken, accountExpiry);
this.redirectToConnect();
} catch (error) {
actions.account.createAccountFailed(error);
}
}
public submitVoucher(voucherCode: string): Promise {
return IpcRendererEventChannel.account.submitVoucher(voucherCode);
}
public async connectTunnel(): Promise {
const state = this.tunnelState.state;
// connect only if tunnel is disconnected or blocked.
if (state === 'disconnecting' || state === 'disconnected' || state === 'error') {
// 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 reconnectTunnel(): Promise {
return IpcRendererEventChannel.tunnel.reconnect();
}
public updateRelaySettings(relaySettings: RelaySettingsUpdate) {
return IpcRendererEventChannel.settings.updateRelaySettings(relaySettings);
}
public updateBridgeSettings(bridgeSettings: BridgeSettings) {
return IpcRendererEventChannel.settings.updateBridgeSettings(bridgeSettings);
}
public setDnsOptions(dns: IDnsOptions) {
return IpcRendererEventChannel.settings.setDnsOptions(dns);
}
public removeAccountFromHistory(accountToken: AccountToken): Promise {
return IpcRendererEventChannel.accountHistory.removeItem(accountToken);
}
public async openLinkWithAuth(link: string): Promise {
let token = '';
try {
token = await IpcRendererEventChannel.account.getWwwAuthToken();
} catch (e) {
log.error(`Failed to get the WWW auth token: ${e.message}`);
}
consumePromise(this.openUrl(`${link}?token=${token}`));
}
public async setAllowLan(allowLan: boolean) {
const actions = this.reduxActions;
await IpcRendererEventChannel.settings.setAllowLan(allowLan);
actions.settings.updateAllowLan(allowLan);
}
public async setShowBetaReleases(showBetaReleases: boolean) {
const actions = this.reduxActions;
await IpcRendererEventChannel.settings.setShowBetaReleases(showBetaReleases);
actions.settings.updateShowBetaReleases(showBetaReleases);
}
public async setEnableIpv6(enableIpv6: boolean) {
const actions = this.reduxActions;
await IpcRendererEventChannel.settings.setEnableIpv6(enableIpv6);
actions.settings.updateEnableIpv6(enableIpv6);
}
public async setBridgeState(bridgeState: BridgeState) {
const actions = this.reduxActions;
await IpcRendererEventChannel.settings.setBridgeState(bridgeState);
actions.settings.updateBridgeState(bridgeState);
}
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 setWireguardMtu(mtu?: number) {
const actions = this.reduxActions;
actions.settings.updateWireguardMtu(mtu);
await IpcRendererEventChannel.settings.setWireguardMtu(mtu);
}
public setAutoConnect(autoConnect: boolean) {
IpcRendererEventChannel.guiSettings.setAutoConnect(autoConnect);
}
public setEnableSystemNotifications(flag: boolean) {
IpcRendererEventChannel.guiSettings.setEnableSystemNotifications(flag);
}
public 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);
}
public setUnpinnedWindow(unpinnedWindow: boolean) {
IpcRendererEventChannel.guiSettings.setUnpinnedWindow(unpinnedWindow);
}
public async verifyWireguardKey(publicKey: IWgKey) {
const actions = this.reduxActions;
actions.settings.verifyWireguardKey(publicKey);
try {
const valid = await IpcRendererEventChannel.wireguardKeys.verifyKey();
actions.settings.completeWireguardKeyVerification(valid);
} catch (error) {
log.error(`Failed to verify WireGuard key - ${error.message}`);
actions.settings.completeWireguardKeyVerification(undefined);
}
}
public async generateWireguardKey() {
const actions = this.reduxActions;
actions.settings.generateWireguardKey();
const keygenEvent = await IpcRendererEventChannel.wireguardKeys.generateKey();
actions.settings.setWireguardKeygenEvent(keygenEvent);
}
public async replaceWireguardKey(oldKey: IWgKey) {
const actions = this.reduxActions;
actions.settings.replaceWireguardKey(oldKey);
const keygenEvent = await IpcRendererEventChannel.wireguardKeys.generateKey();
actions.settings.setWireguardKeygenEvent(keygenEvent);
}
public getSplitTunnelingApplications() {
return IpcRendererEventChannel.splitTunneling.getApplications();
}
public launchExcludedApplication(
application: ILinuxSplitTunnelingApplication | string,
): Promise {
return IpcRendererEventChannel.splitTunneling.launchApplication(application);
}
public collectProblemReport(toRedact: string[]): Promise {
return IpcRendererEventChannel.problemReport.collectLogs(toRedact);
}
public async sendProblemReport(
email: string,
message: string,
savedReport: string,
): Promise {
await IpcRendererEventChannel.problemReport.sendReport({ email, message, savedReport });
}
public quit(): void {
IpcRendererEventChannel.app.quit();
}
public openUrl(url: string): Promise {
return IpcRendererEventChannel.app.openUrl(url);
}
public openPath(path: string): Promise {
return IpcRendererEventChannel.app.openPath(path);
}
public showOpenDialog(
options: Electron.OpenDialogOptions,
): Promise {
return IpcRendererEventChannel.app.showOpenDialog(options);
}
public getPreferredLocaleList(): IPreferredLocaleDescriptor[] {
return [
{
// TRANSLATORS: The option that represents the active operating system language in the
// TRANSLATORS: user interface language selection list.
name: messages.gettext('System default'),
code: SYSTEM_PREFERRED_LOCALE_KEY,
},
...SUPPORTED_LOCALE_LIST,
];
}
public async setPreferredLocale(preferredLocale: string): Promise {
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 {
const preferredLocale = this.getPreferredLocaleList().find((item) => item.code === localeCode);
return preferredLocale ? preferredLocale.name : '';
}
private redirectToConnect() {
// Redirect the user after some time to allow for the 'Logged in' screen to be visible
this.loginScheduler.schedule(() => this.resetNavigation(), 1000);
}
private setLocale(locale: string) {
this.locale = locale;
this.reduxActions.userInterface.updateLocale(locale);
}
private setRelaySettings(relaySettings: RelaySettings) {
const actions = this.reduxActions;
if ('normal' in relaySettings) {
const {
location,
openvpnConstraints,
wireguardConstraints,
tunnelProtocol,
} = relaySettings.normal;
actions.settings.updateRelay({
normal: {
location: liftConstraint(location),
openvpn: {
port: liftConstraint(openvpnConstraints.port),
protocol: liftConstraint(openvpnConstraints.protocol),
},
wireguard: { port: liftConstraint(wireguardConstraints.port) },
tunnelProtocol: liftConstraint(tunnelProtocol),
},
});
} 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 setBridgeSettings(bridgeSettings: BridgeSettings) {
const actions = this.reduxActions;
if ('normal' in bridgeSettings) {
actions.settings.updateBridgeSettings({
normal: {
location: liftConstraint(bridgeSettings.normal.location),
},
});
} else if ('custom' in bridgeSettings) {
actions.settings.updateBridgeSettings({
custom: bridgeSettings.custom,
});
}
}
private async onDaemonConnected() {
this.connectedToDaemon = true;
await this.autoConnect();
this.resetNavigation();
}
private onDaemonDisconnected() {
this.connectedToDaemon = false;
this.resetNavigation();
}
private resetNavigation() {
if (this.connectedToDaemon) {
if (this.settings.accountToken) {
this.history.resetWithIfDifferent('/connect');
} else {
this.history.resetWithIfDifferent('/login');
}
} else {
this.history.resetWithIfDifferent('/');
}
}
private async autoConnect() {
if (window.runningInDevelopment) {
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: TunnelState) {
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 'error':
actions.connection.blocked(tunnelState.details);
break;
}
}
private setSettings(newSettings: ISettings) {
this.settings = newSettings;
const reduxSettings = this.reduxActions.settings;
reduxSettings.updateAllowLan(newSettings.allowLan);
reduxSettings.updateEnableIpv6(newSettings.tunnelOptions.generic.enableIpv6);
reduxSettings.updateBlockWhenDisconnected(newSettings.blockWhenDisconnected);
reduxSettings.updateShowBetaReleases(newSettings.showBetaReleases);
reduxSettings.updateOpenVpnMssfix(newSettings.tunnelOptions.openvpn.mssfix);
reduxSettings.updateWireguardMtu(newSettings.tunnelOptions.wireguard.mtu);
reduxSettings.updateBridgeState(newSettings.bridgeState);
reduxSettings.updateDnsOptions(newSettings.tunnelOptions.dns);
this.setRelaySettings(newSettings.relaySettings);
this.setBridgeSettings(newSettings.bridgeSettings);
}
private updateBlockedState(tunnelState: TunnelState, 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 'error':
actions.updateBlockState(!tunnelState.details.blockFailure);
break;
}
}
private handleAccountChange(oldAccount?: string, newAccount?: string) {
const reduxAccount = this.reduxActions.account;
if (oldAccount && !newAccount) {
this.loginScheduler.cancel();
reduxAccount.loggedOut();
this.resetNavigation();
} else if (newAccount && oldAccount !== newAccount && !this.doingLogin) {
reduxAccount.updateAccountToken(newAccount);
reduxAccount.loggedIn();
this.resetNavigation();
}
this.doingLogin = false;
}
private setLocation(location: ILocation) {
this.location = location;
this.propagateLocationToRedux();
}
private propagateLocationToRedux() {
if (this.location) {
this.reduxActions.connection.newLocation(this.translateLocation(this.location));
}
}
private translateLocation(inputLocation: ILocation): ILocation {
const location = { ...inputLocation };
if (location.city) {
const city = location.city;
location.city = relayLocations.gettext(city) || city;
}
if (location.country) {
const country = location.country;
location.country = relayLocations.gettext(country) || country;
}
return location;
}
private convertRelayListToLocationList(relayList: IRelayList): IRelayLocationRedux[] {
return relayList.countries
.map((country) => ({
name: relayLocations.gettext(country.name) || country.name,
code: country.code,
hasActiveRelays: country.cities.some((city) => city.relays.some((relay) => relay.active)),
cities: country.cities
.map((city) => ({
name: relayLocations.gettext(city.name) || city.name,
code: city.code,
latitude: city.latitude,
longitude: city.longitude,
hasActiveRelays: city.relays.some((relay) => relay.active),
relays: city.relays.sort((relayA, relayB) =>
relayA.hostname.localeCompare(relayB.hostname, this.locale, { numeric: true }),
),
}))
.sort((cityA, cityB) => cityA.name.localeCompare(cityB.name, this.locale)),
}))
.sort((countryA, countryB) => countryA.name.localeCompare(countryB.name, this.locale));
}
private setRelayListPair(relayListPair: IRelayListPair) {
this.relayListPair = relayListPair;
this.propagateRelayListPairToRedux();
}
private propagateRelayListPairToRedux() {
const relays = this.convertRelayListToLocationList(this.relayListPair.relays);
const bridges = this.convertRelayListToLocationList(this.relayListPair.bridges);
this.reduxActions.settings.updateRelayLocations(relays);
this.reduxActions.settings.updateBridgeLocations(bridges);
}
private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
this.reduxActions.version.updateVersion(
versionInfo.gui,
versionInfo.isConsistent,
versionInfo.isBeta,
);
}
private setUpgradeVersion(upgradeVersion: IAppVersionInfo) {
this.reduxActions.version.updateLatest(upgradeVersion);
}
private setGuiSettings(guiSettings: IGuiSettingsState) {
this.guiSettings = guiSettings;
this.reduxActions.settings.updateGuiSettings(guiSettings);
}
private setAccountExpiry(expiry?: string) {
this.reduxActions.account.updateAccountExpiry(expiry);
}
private storeAutoStart(autoStart: boolean) {
this.reduxActions.settings.updateAutoStart(autoStart);
}
private setWireguardPublicKey(publicKey?: IWireguardPublicKey) {
this.reduxActions.settings.setWireguardKey(publicKey);
}
}