diff options
Diffstat (limited to 'app/lib')
| -rw-r--r-- | app/lib/backend.js | 476 | ||||
| -rw-r--r-- | app/lib/formatters.js | 9 | ||||
| -rw-r--r-- | app/lib/ipc-facade.js | 333 | ||||
| -rw-r--r-- | app/lib/jsonrpc-ws-ipc.js | 290 | ||||
| -rw-r--r-- | app/lib/keyframe-animation.js | 226 | ||||
| -rw-r--r-- | app/lib/platform.android.js | 13 | ||||
| -rw-r--r-- | app/lib/platform.js | 13 | ||||
| -rw-r--r-- | app/lib/proc.js | 27 | ||||
| -rw-r--r-- | app/lib/relay-settings-builder.js | 195 | ||||
| -rw-r--r-- | app/lib/transition-rule.js | 47 | ||||
| -rw-r--r-- | app/lib/tray-icon-manager.js | 58 |
11 files changed, 1687 insertions, 0 deletions
diff --git a/app/lib/backend.js b/app/lib/backend.js new file mode 100644 index 0000000000..f78d359926 --- /dev/null +++ b/app/lib/backend.js @@ -0,0 +1,476 @@ +// @flow + +import log from 'electron-log'; +import EventEmitter from 'events'; +import { IpcFacade, RealIpc } from './ipc-facade'; +import accountActions from '../redux/account/actions'; +import connectionActions from '../redux/connection/actions'; +import settingsActions from '../redux/settings/actions'; +import { push } from 'react-router-redux'; + +import type { ReduxStore } from '../redux/store'; +import type { AccountToken, BackendState, RelaySettingsUpdate } from './ipc-facade'; +import type { ConnectionState } from '../redux/connection/reducers'; + +export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT' | 'NO_ACCOUNT'; + +export class BackendError extends Error { + type: ErrorType; + title: string; + message: string; + + constructor(type: ErrorType) { + super(''); + this.type = type; + this.title = BackendError.localizedTitle(type); + this.message = BackendError.localizedMessage(type); + } + + static localizedTitle(type: ErrorType): string { + switch(type) { + case 'NO_CREDIT': + return 'Out of time'; + case 'NO_INTERNET': + return 'Offline'; + default: + return 'Something went wrong'; + } + } + + static localizedMessage(type: ErrorType): string { + switch(type) { + case 'NO_CREDIT': + return 'Buy more time, so you can continue using the internet securely'; + case 'NO_INTERNET': + return 'Your internet connection will be secured when you get back online'; + case 'INVALID_ACCOUNT': + return 'Invalid account number'; + case 'NO_ACCOUNT': + return 'No account was set'; + default: + return ''; + } + } + +} + + +export type IpcCredentials = { + connectionString: string, + sharedSecret: string, +}; +export function parseIpcCredentials(data: string): ?IpcCredentials { + const [connectionString, sharedSecret] = data.split('\n', 2); + if(connectionString && sharedSecret) { + return { + connectionString, + sharedSecret, + }; + } else { + return null; + } +} + + +/** + * Backend implementation + */ +export class Backend { + + _ipc: IpcFacade; + _credentials: ?IpcCredentials; + _authenticationPromise: ?Promise<void>; + _store: ReduxStore; + _eventEmitter = new EventEmitter(); + + constructor(store: ReduxStore, credentials?: IpcCredentials, ipc: ?IpcFacade) { + this._store = store; + this._credentials = credentials; + + + if(ipc) { + this._ipc = ipc; + + // force to re-authenticate when connection closed + this._ipc.setCloseConnectionHandler(() => { + this._authenticationPromise = null; + }); + + this._registerIpcListeners(); + this._startReachability(); + } + } + + setCredentials(credentials: IpcCredentials) { + log.debug('Got connection info to backend', credentials.connectionString); + this._credentials = credentials; + + if (this._ipc) { + this._credentials = credentials; + } else { + this._ipc = new RealIpc(credentials.connectionString); + + // force to re-authenticate when connection closed + this._ipc.setCloseConnectionHandler(() => { + this._authenticationPromise = null; + }); + } + this._registerIpcListeners(); + } + + async sync() { + log.info('Syncing with the backend...'); + + try { + await this._fetchRelayLocations(); + } catch(e) { + log.error('Failed to fetch the relay locations: ', e.message); + } + + try { + await this._fetchPublicIP(); + } catch(e) { + log.error('Failed to fetch the public IP: ', e.message); + } + + try { + await this._fetchLocation(); + } catch(e) { + log.error('Failed to fetch the location: ', e.message); + } + + await this._fetchAccountHistory(); + } + + async login(accountToken: AccountToken) { + log.debug('Attempting to login'); + + this._store.dispatch(accountActions.startLogin(accountToken)); + + try { + await this._ensureAuthenticated(); + + const accountData = await this._ipc.getAccountData(accountToken); + + log.debug('Account exists', accountData); + + await this._ipc.setAccount(accountToken); + + log.info('Log in complete'); + + this._store.dispatch( + accountActions.loginSuccessful(accountData.expiry) + ); + await this.fetchRelaySettings(); + + // Redirect the user after some time to allow for + // the 'Login Successful' screen to be visible + setTimeout(() => { + this._store.dispatch(push('/connect')); + log.debug('Autoconnecting...'); + this.connect(); + }, 1000); + + await this._fetchAccountHistory(); + + } catch(e) { + log.error('Failed to log in,', e.message); + + // TODO: This is not true. If there is a communication link failure the promise will be rejected too + const err = new BackendError('INVALID_ACCOUNT'); + this._store.dispatch(accountActions.loginFailed(err)); + } + } + + async autologin() { + try { + log.debug('Attempting to log in automatically'); + + await this._ensureAuthenticated(); + + this._store.dispatch(accountActions.startLogin()); + + const accountToken = await this._ipc.getAccount(); + if(!accountToken) { + throw new BackendError('NO_ACCOUNT'); + } + + log.debug('The backend had an account number stored: ', accountToken); + this._store.dispatch(accountActions.startLogin(accountToken)); + + const accountData = await this._ipc.getAccountData(accountToken); + log.debug('The stored account number still exists', accountData); + + this._store.dispatch(accountActions.loginSuccessful(accountData.expiry)); + this._store.dispatch(push('/connect')); + } catch (e) { + log.warn('Unable to autologin,', e.message); + + this._store.dispatch(accountActions.autoLoginFailed()); + this._store.dispatch(push('/')); + + throw e; + } + } + + async logout() { + // @TODO: What does it mean for a logout to be successful or failed? + try { + await this._ensureAuthenticated(); + await this._ipc.setAccount(null); + + this._store.dispatch(accountActions.loggedOut()); + + // disconnect user during logout + await this.disconnect(); + + this._store.dispatch(push('/')); + } catch (e) { + log.info('Failed to logout: ', e.message); + } + } + + async connect() { + try { + this._store.dispatch(connectionActions.connecting()); + + await this._ensureAuthenticated(); + await this._ipc.connect(); + } catch (e) { + log.error('Failed to connect: ', e.message); + this._store.dispatch(connectionActions.disconnected()); + } + } + + async disconnect() { + // @TODO: Failure modes + try { + await this._ensureAuthenticated(); + await this._ipc.disconnect(); + } catch (e) { + log.error('Failed to disconnect: ', e.message); + } + } + + async shutdown() { + try { + await this._ensureAuthenticated(); + await this._ipc.shutdown(); + } catch (e) { + log.error('Failed to shutdown: ', e.message); + } + } + + async updateRelaySettings(relaySettings: RelaySettingsUpdate) { + try { + await this._ensureAuthenticated(); + await this._ipc.updateRelaySettings(relaySettings); + } catch (e) { + log.error('Failed to update relay settings: ', e.message); + } + } + + async fetchRelaySettings() { + await this._ensureAuthenticated(); + + const relaySettings = await this._ipc.getRelaySettings(); + log.debug('Got relay settings from backend', JSON.stringify(relaySettings)); + + if(relaySettings.normal) { + const payload = {}; + const normal = relaySettings.normal; + const tunnel = normal.tunnel; + const location = normal.location; + + if(location === 'any') { + payload.location = 'any'; + } else { + payload.location = location.only; + } + + if(tunnel === 'any') { + payload.port = 'any'; + payload.protocol = 'any'; + } else { + const { port, protocol } = tunnel.only.openvpn; + payload.port = port === 'any' ? port : port.only; + payload.protocol = protocol === 'any' ? protocol : protocol.only; + } + + this._store.dispatch( + settingsActions.updateRelay({ + normal: payload + }) + ); + } else if(relaySettings.custom_tunnel_endpoint) { + const custom_tunnel_endpoint = relaySettings.custom_tunnel_endpoint; + const { host, tunnel: { openvpn: { port, protocol } } } = custom_tunnel_endpoint; + + this._store.dispatch( + settingsActions.updateRelay({ + custom_tunnel_endpoint: { + host, port, protocol + } + }) + ); + } + } + + async removeAccountFromHistory(accountToken: AccountToken) { + try { + await this._ensureAuthenticated(); + await this._ipc.removeAccountFromHistory(accountToken); + await this._fetchAccountHistory(); + } catch(e) { + log.error('Failed to remove account token from history', e.message); + } + } + + async _fetchAccountHistory() { + try { + await this._ensureAuthenticated(); + const accountHistory = await this._ipc.getAccountHistory(); + this._store.dispatch( + accountActions.updateAccountHistory(accountHistory) + ); + } catch(e) { + log.info('Failed to fetch account history,', e.message); + throw e; + } + } + + + + async _fetchRelayLocations() { + await this._ensureAuthenticated(); + + const locations = await this._ipc.getRelayLocations(); + + log.info('Got relay locations'); + + const storedLocations = locations.countries.map((country) => ({ + name: country.name, + code: country.code, + hasActiveRelays: country.cities.some((city) => city.has_active_relays), + cities: country.cities.map((city) => ({ + name: city.name, + code: city.code, + position: city.position, + hasActiveRelays: city.has_active_relays, + })) + })); + + this._store.dispatch( + settingsActions.updateRelayLocations(storedLocations) + ); + } + + async _fetchPublicIP() { + await this._ensureAuthenticated(); + + const publicIp = await this._ipc.getPublicIp(); + + log.info('Got public IP: ', publicIp); + + this._store.dispatch( + connectionActions.newPublicIp(publicIp) + ); + } + + async _fetchLocation() { + await this._ensureAuthenticated(); + + const location = await this._ipc.getLocation(); + + log.info('Got location: ', location); + + const locationUpdate = { + country: location.country, + city: location.city, + location: location.position + }; + + this._store.dispatch( + connectionActions.newLocation(locationUpdate) + ); + } + + /** + * Start reachability monitoring for online/offline detection + * This is currently done via HTML5 APIs but will be replaced later + * with proper backend integration. + */ + _startReachability() { + window.addEventListener('online', () => { + this._store.dispatch(connectionActions.online()); + }); + window.addEventListener('offline', () => { + // force disconnect since there is no real connection anyway. + this.disconnect(); + this._store.dispatch(connectionActions.offline()); + }); + + // update online status in background + setTimeout(() => { + const action = navigator.onLine + ? connectionActions.online() + : connectionActions.offline(); + + this._store.dispatch(action); + }, 0); + } + + async _registerIpcListeners() { + await this._ensureAuthenticated(); + this._ipc.registerStateListener(newState => { + log.debug('Got new state from backend', newState); + + const newStatus = this._securityStateToConnectionState(newState); + switch(newStatus) { + case 'connecting': + this._store.dispatch(connectionActions.connecting()); + break; + case 'connected': + this._store.dispatch(connectionActions.connected()); + break; + case 'disconnected': + this._store.dispatch(connectionActions.disconnected()); + break; + } + this.sync(); + }); + } + + _securityStateToConnectionState(backendState: BackendState): ConnectionState { + if (backendState.state === 'unsecured' && backendState.target_state === 'secured') { + return 'connecting'; + } else if (backendState.state === 'secured' && backendState.target_state === 'secured') { + return 'connected'; + } else if (backendState.target_state === 'unsecured') { + return 'disconnected'; + } + throw new Error('Unsupported state/target state combination: ' + JSON.stringify(backendState)); + } + + _ensureAuthenticated() { + const credentials = this._credentials; + if(credentials) { + if(!this._authenticationPromise) { + this._authenticationPromise = this._authenticate(credentials.sharedSecret); + } + return this._authenticationPromise; + } else { + return Promise.reject(new Error('Missing authentication credentials.')); + } + } + + async _authenticate(sharedSecret: string) { + try { + await this._ipc.authenticate(sharedSecret); + log.info('Authenticated with backend'); + } catch (e) { + log.error('Failed to authenticate with backend: ', e.message); + throw e; + } + } +} diff --git a/app/lib/formatters.js b/app/lib/formatters.js new file mode 100644 index 0000000000..89d45d44a0 --- /dev/null +++ b/app/lib/formatters.js @@ -0,0 +1,9 @@ +// @flow +export const formatAccount = (val: string): string => { + // display number altogether when longer than 12 + if(val.length > 12) { + return val; + } + // display quartets + return val.replace(/([0-9]{4})/g, '$1 ').trim(); +};
\ No newline at end of file diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js new file mode 100644 index 0000000000..567a6ab8f1 --- /dev/null +++ b/app/lib/ipc-facade.js @@ -0,0 +1,333 @@ +// @flow + +import JsonRpcWs, { InvalidReply } from './jsonrpc-ws-ipc'; +import { object, string, number, boolean, enumeration, arrayOf, oneOf } from 'validated/schema'; +import { validate } from 'validated/object'; + +import type { Coordinate2d } from '../types'; + +export type AccountData = { expiry: string }; +export type AccountToken = string; +export type Ip = string; +export type Location = { + country: string, + city: string, + position: Coordinate2d, +}; +const LocationSchema = object({ + country: string, + country_code: string, + city: string, + city_code: string, + position: arrayOf(number), +}); + +export type SecurityState = 'secured' | 'unsecured'; +export type BackendState = { + state: SecurityState, + target_state: SecurityState, +}; + +export type RelayProtocol = 'tcp' | 'udp'; +export type RelayLocation = {| city: [string, string] |} | {| country: string |}; + +type OpenVpnParameters = { + port: 'any' | { only: number }, + protocol: 'any' | { only: RelayProtocol }, +}; + +type TunnelOptions<TOpenVpnParameters> = { + openvpn: TOpenVpnParameters, +}; + +type RelaySettingsNormal<TTunnelOptions> = { + location: 'any' | { + only: RelayLocation, + }, + tunnel: 'any' | { + only: TTunnelOptions + }, +}; + +// types describing the structure of RelaySettings +export type RelaySettingsCustom = { + host: string, + tunnel: { + openvpn: { + port: number, + protocol: RelayProtocol + } + } +}; +export type RelaySettings = {| + normal: RelaySettingsNormal<TunnelOptions<OpenVpnParameters>> +|} | {| + custom_tunnel_endpoint: RelaySettingsCustom +|}; + +// types describing the partial update of RelaySettings +export type RelaySettingsNormalUpdate = $Shape< RelaySettingsNormal< TunnelOptions<$Shape<OpenVpnParameters> > > >; +export type RelaySettingsUpdate = {| + normal: RelaySettingsNormalUpdate +|} | {| + custom_tunnel_endpoint: RelaySettingsCustom +|}; + +const Constraint = (v) => oneOf(string, object({ + only: v, +})); +const RelaySettingsSchema = oneOf( + object({ + normal: object({ + location: Constraint(oneOf( + object({ + city: arrayOf(string), + }), + object({ + country: string + }), + )), + tunnel: Constraint(object({ + openvpn: object({ + port: Constraint(number), + protocol: Constraint(enumeration('udp', 'tcp')), + }), + })), + }) + }), + object({ + custom_tunnel_endpoint: object({ + host: string, + tunnel: object({ + openvpn: object({ + port: number, + protocol: enumeration('udp', 'tcp'), + }) + }) + }) + }) +); + +export type RelayList = { + countries: Array<RelayListCountry>, +}; + +export type RelayListCountry = { + name: string, + code: string, + cities: Array<RelayListCity>, +}; + +export type RelayListCity = { + name: string, + code: string, + position: [number, number], + has_active_relays: boolean, +}; + +const RelayListSchema = object({ + countries: arrayOf(object({ + name: string, + code: string, + cities: arrayOf(object({ + name: string, + code: string, + position: arrayOf(number), + has_active_relays: boolean, + })), + })), +}); + + +export interface IpcFacade { + setConnectionString(string): void, + getAccountData(AccountToken): Promise<AccountData>, + getAccount(): Promise<?AccountToken>, + setAccount(accountToken: ?AccountToken): Promise<void>, + updateRelaySettings(RelaySettingsUpdate): Promise<void>, + getRelaySettings(): Promise<RelaySettings>, + getRelayLocations(): Promise<RelayList>, + connect(): Promise<void>, + disconnect(): Promise<void>, + shutdown(): Promise<void>, + getPublicIp(): Promise<Ip>, + getLocation(): Promise<Location>, + getState(): Promise<BackendState>, + registerStateListener((BackendState) => void): void, + setCloseConnectionHandler(() => void): void, + authenticate(sharedSecret: string): Promise<void>, + getAccountHistory(): Promise<Array<AccountToken>>, + removeAccountFromHistory(accountToken: AccountToken): Promise<void>, +} + +export class RealIpc implements IpcFacade { + + _ipc: JsonRpcWs; + + constructor(connectionString: string) { + this._ipc = new JsonRpcWs(connectionString); + } + + setConnectionString(str: string) { + this._ipc.setConnectionString(str); + } + + getAccountData(accountToken: AccountToken): Promise<AccountData> { + // send the IPC with 30s timeout since the backend will wait + // for a HTTP request before replying + + return this._ipc.send('get_account_data', accountToken, 30000) + .then(raw => { + if (typeof raw === 'object' && raw && raw.expiry) { + return raw; + } else { + throw new InvalidReply(raw, 'Expected an object with expiry'); + } + }); + } + + getAccount(): Promise<?AccountToken> { + return this._ipc.send('get_account') + .then( raw => { + if (raw === undefined || raw === null || typeof raw === 'string') { + return raw; + } else { + throw new InvalidReply(raw); + } + }); + } + + setAccount(accountToken: ?AccountToken): Promise<void> { + return this._ipc.send('set_account', accountToken) + .then(this._ignoreResponse); + } + + _ignoreResponse(_response: mixed): void { + return; + } + + updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> { + return this._ipc.send('update_relay_settings', [relaySettings]) + .then(this._ignoreResponse); + } + + getRelaySettings(): Promise<RelaySettings> { + return this._ipc.send('get_relay_settings') + .then( raw => { + try { + const validated: any = validate(RelaySettingsSchema, raw); + return (validated: RelaySettings); + } catch (e) { + throw new InvalidReply(raw, e); + } + }); + } + + async getRelayLocations(): Promise<RelayList> { + const raw = await this._ipc.send('get_relay_locations'); + try { + const validated: any = validate(RelayListSchema, raw); + return (validated: RelayList); + } catch (e) { + throw new InvalidReply(raw, e); + } + } + + connect(): Promise<void> { + return this._ipc.send('connect') + .then(this._ignoreResponse); + } + + disconnect(): Promise<void> { + return this._ipc.send('disconnect') + .then(this._ignoreResponse); + } + + shutdown(): Promise<void> { + return this._ipc.send('shutdown') + .then(this._ignoreResponse); + } + + getPublicIp(): Promise<Ip> { + return this._ipc.send('get_public_ip') + .then(raw => { + if (typeof raw === 'string' && raw) { + return raw; + } else { + throw new InvalidReply(raw, 'Expected a string'); + } + }); + } + + getLocation(): Promise<Location> { + return this._ipc.send('get_current_location') + .then(raw => { + try { + const validated: any = validate(LocationSchema, raw); + return (validated: Location); + } catch (e) { + throw new InvalidReply(raw, e); + } + }); + } + + getState(): Promise<BackendState> { + return this._ipc.send('get_state') + .then(raw => { + return this._parseBackendState(raw); + }); + } + + _parseBackendState(raw: mixed): BackendState { + if (raw && raw.state && raw.target_state) { + + const uncheckedRaw: any = raw; + + const states: Array<SecurityState> = ['secured', 'unsecured']; + const correctState = states.includes(uncheckedRaw.state); + const correctTargetState = states.includes(uncheckedRaw.target_state); + + if (!correctState || !correctTargetState) { + throw new InvalidReply(raw); + } + + return (uncheckedRaw: BackendState); + } else { + throw new InvalidReply(raw); + } + } + + registerStateListener(listener: (BackendState) => void) { + this._ipc.on('new_state', (rawEvent) => { + const parsedEvent : BackendState = this._parseBackendState(rawEvent); + + listener(parsedEvent); + }); + } + + setCloseConnectionHandler(handler: () => void) { + this._ipc.setCloseConnectionHandler(handler); + } + + authenticate(sharedSecret: string): Promise<void> { + return this._ipc.send('auth', sharedSecret) + .then(this._ignoreResponse); + } + + getAccountHistory(): Promise<Array<AccountToken>> { + return this._ipc.send('get_account_history') + .then(raw => { + if(Array.isArray(raw) && raw.every(i => typeof i === 'string')) { + const checked: any = raw; + return (checked: Array<AccountToken>); + } else { + throw new InvalidReply(raw, 'Expected an array of strings'); + } + }); + } + + removeAccountFromHistory(accountToken: AccountToken): Promise<void> { + return this._ipc.send('remove_account_from_history', accountToken) + .then(this._ignoreResponse); + } +} diff --git a/app/lib/jsonrpc-ws-ipc.js b/app/lib/jsonrpc-ws-ipc.js new file mode 100644 index 0000000000..909b4b0775 --- /dev/null +++ b/app/lib/jsonrpc-ws-ipc.js @@ -0,0 +1,290 @@ +// @flow + +import jsonrpc from 'jsonrpc-lite'; +import uuid from 'uuid'; +import log from 'electron-log'; + +export type UnansweredRequest = { + resolve: (mixed) => void, + reject: (mixed) => void, + timerId: number, + message: Object, +} + +export type JsonRpcError = { + type: 'error', + payload: { + id: string, + error: { + message: string, + } + } +} +export type JsonRpcNotification = { + type: 'notification', + payload: { + method: string, + params: { + subscription: string, + result: mixed, + } + } +} +export type JsonRpcSuccess = { + type: 'success', + payload: { + id: string, + result: mixed, + } +} +export type JsonRpcMessage = JsonRpcError | JsonRpcNotification | JsonRpcSuccess; + +export class TimeOutError extends Error { + jsonRpcMessage: Object; + + constructor(jsonRpcMessage: Object) { + super('Request timed out'); + this.name = 'TimeOutError'; + this.jsonRpcMessage = jsonRpcMessage; + } +} + +export class InvalidReply extends Error { + reply: mixed; + + constructor(reply: mixed, msg: ?string) { + super(msg); + this.name = 'InvalidReply'; + this.reply = reply; + + if(msg) { + this.message = msg + ' - '; + } + this.message += JSON.stringify(reply); + } +} + +const DEFAULT_TIMEOUT_MILLIS = 5000; + +export default class Ipc { + + _connectionString: ?string; + _onConnect: Array<{resolve: ()=>void}>; + _unansweredRequests: Map<string, UnansweredRequest>; + _subscriptions: Map<string|number, (mixed) => void>; + _websocket: WebSocket; + _backoff: ReconnectionBackoff; + _websocketFactory: (string) => WebSocket; + _closeConnectionHandler: ?() => void; + + constructor(connectionString: string, websocketFactory: ?(string)=>WebSocket) { + this._connectionString = connectionString; + this._onConnect = []; + this._unansweredRequests = new Map(); + this._subscriptions = new Map(); + this._websocketFactory = websocketFactory || (connectionString => new WebSocket(connectionString)); + + this._backoff = new ReconnectionBackoff(); + this._reconnect(); + } + + setConnectionString(str: string) { + this._connectionString = str; + } + + setCloseConnectionHandler(handler: ?() => void) { + this._closeConnectionHandler = handler; + } + + on(event: string, listener: (mixed) => void): Promise<*> { + + log.debug('Adding a listener to', event); + return this.send(event + '_subscribe') + .then(subscriptionId => { + if (typeof subscriptionId === 'string' || typeof subscriptionId === 'number') { + this._subscriptions.set(subscriptionId, listener); + } else { + throw new InvalidReply(subscriptionId, 'The subscription id was not a string or a number'); + } + }) + .catch(e => { + log.error('Failed adding listener to', event, ':', e); + }); + } + + send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> { + return new Promise((resolve, reject) => { + const id = uuid.v4(); + + const params = this._prepareParams(data); + const timerId = setTimeout(() => this._onTimeout(id), timeout); + const jsonrpcMessage = jsonrpc.request(id, action, params); + this._unansweredRequests.set(id, { + resolve: resolve, + reject: reject, + timerId: timerId, + message: jsonrpcMessage, + }); + + this._getWebSocket() + .then(ws => { + log.debug('Sending message', id, action); + ws.send(jsonrpcMessage); + }) + .catch(e => { + log.error('Failed sending RPC message "' + action + '":', e); + reject(e); + }); + }); + } + + _prepareParams(data: mixed): Array<mixed>|Object { + // JSONRPC only accepts arrays and objects as params, but + // this isn't very nice to use, so this method wraps other + // types in an array. The choice of array is based on try-and-error + + if(data === undefined) { + return []; + } else if (data === null) { + return [null]; + } else if (Array.isArray(data) || typeof(data) === 'object') { + return data; + } else { + return [data]; + } + } + + _getWebSocket() { + return new Promise(resolve => { + if (this._websocket && this._websocket.readyState === 1) { // Connected + resolve(this._websocket); + } else { + log.debug('Waiting for websocket to connect'); + this._onConnect.push({ + resolve: () => resolve(this._websocket), + }); + } + }); + } + + _onTimeout(requestId) { + const request = this._unansweredRequests.get(requestId); + this._unansweredRequests.delete(requestId); + + if (!request) { + log.debug(requestId, 'timed out but it seems to already have been answered'); + return; + } + + log.debug(request.message, 'timed out'); + request.reject(new TimeOutError(request.message)); + } + + _onMessage(message: string) { + const json = JSON.parse(message); + const c = jsonrpc.parseObject(json); + + if (c.type === 'notification') { + this._onNotification(c); + } else { + this._onReply(c); + } + } + + _onNotification(message: JsonRpcNotification) { + const subscriptionId = message.payload.params.subscription; + const listener = this._subscriptions.get(subscriptionId); + + if (listener) { + log.debug('Got notification', message.payload.method, message.payload.params.result); + listener(message.payload.params.result); + } else { + log.warn('Got notification for', message.payload.method, 'but no one is listening for it'); + } + } + + _onReply(message: JsonRpcError | JsonRpcSuccess) { + const id = message.payload.id; + const request = this._unansweredRequests.get(id); + this._unansweredRequests.delete(id); + + if (!request) { + log.warn('Got reply to', id, 'but no one was waiting for it'); + return; + } + + log.debug('Got answer to', id, message.type); + + clearTimeout(request.timerId); + + if (message.type === 'error') { + request.reject(message.payload.error); + } else { + const reply = message.payload.result; + request.resolve(reply); + } + } + + _reconnect() { + const connectionString = this._connectionString; + if (!connectionString) return; + + log.info('Connecting to websocket', connectionString); + this._websocket = this._websocketFactory(connectionString); + + this._websocket.onopen = () => { + log.debug('Websocket is connected'); + this._backoff.successfullyConnected(); + + while(this._onConnect.length > 0) { + this._onConnect.pop().resolve(); + } + }; + + this._websocket.onmessage = (evt) => { + const data = evt.data; + if (typeof data === 'string') { + this._onMessage(data); + } else { + log.error('Got invalid reply from the server', evt); + } + }; + + this._websocket.onclose = () => { + if(this._closeConnectionHandler) { + this._closeConnectionHandler(); + } + + const delay = this._backoff.getIncreasedBackoff(); + log.warn('The websocket connetion closed, attempting to reconnect it in', delay, 'milliseconds'); + setTimeout(() => this._reconnect(), delay); + }; + } +} + +/* + * Used to calculate the time to wait before reconnecting + * the websocket. + * + * It uses a linear backoff function that goes from 500ms + * to 3000ms + */ +class ReconnectionBackoff { + _attempt: number; + + constructor() { + this._attempt = 0; + } + + successfullyConnected() { + this._attempt = 0; + } + + getIncreasedBackoff() { + if (this._attempt < 6) { + this._attempt++; + } + + return this._attempt * 500; + } +} diff --git a/app/lib/keyframe-animation.js b/app/lib/keyframe-animation.js new file mode 100644 index 0000000000..62d9be6cd8 --- /dev/null +++ b/app/lib/keyframe-animation.js @@ -0,0 +1,226 @@ +// @flow +import { nativeImage } from 'electron'; +import type { NativeImage } from 'electron'; + +export type OnFrameFn = (image: NativeImage) => void; +export type OnFinishFn = (void) => void; +export type KeyframeAnimationOptions = { + startFrame?: number, + endFrame?: number, + beginFromCurrentState?: boolean, + advanceTo?: 'end' +}; +export type KeyframeAnimationRange = [number, number]; + +export default class KeyframeAnimation { + + _speed: number = 200; // ms + _repeat: boolean = false; + _reverse: boolean = false; + _alternate: boolean = false; + + _onFrame: ?OnFrameFn; + _onFinish: ?OnFinishFn; + + _nativeImages: Array<NativeImage>; + _frameRange: KeyframeAnimationRange; + _numFrames: number; + _currentFrame: number = 0; + + _isRunning: boolean = false; + _isFinished: boolean = false; + _isFirstRun: boolean = true; + + _timeout = null; + + set onFrame(newValue: ?OnFrameFn) { this._onFrame = newValue; } + get onFrame(): ?OnFrameFn { this._onFrame; } + + // called when animation finished for non-repeating animations. + set onFinish(newValue: ?OnFinishFn) { this._onFinish = newValue; } + get onFinish(): ?OnFinishFn { this._onFinish; } + + // pace per frame in ms + set speed(newValue: number) { this._speed = parseInt(newValue); } + get speed(): number { return this._speed; } + + set repeat(newValue: boolean) { this._repeat = newValue; } + get repeat(): boolean { return this._repeat; } + + set reverse(newValue: boolean) { this._reverse = newValue; } + get reverse(): boolean { return this._repeat; } + + // alternates the animation direction when it reaches the end + // only for repeating animations + set alternate(newValue: boolean) { this._alternate = !!newValue; } + get alternate(): boolean { return this._alternate; } + + get nativeImages(): Array<NativeImage> { return this._nativeImages.slice(); } + get isFinished(): boolean { return this._isFinished; } + + // create animation from files matching filename pattern. i.e (bubble-frame-{}.png) + static fromFilePattern(filePattern: string, range: KeyframeAnimationRange): KeyframeAnimation { + const images: Array<NativeImage> = []; + + if(range.length !== 2 || range[0] > range[1]) { + throw new Error('the animation range is invalid'); + } + + for(let i = range[0]; i <= range[1]; i++) { + const filePath = filePattern.replace('{}', i.toString()); + const image = nativeImage.createFromPath(filePath); + images.push(image); + } + return new KeyframeAnimation(images); + } + + static fromFileSequence(files: Array<string>): KeyframeAnimation { + const images: Array<NativeImage> = files.map(filePath => nativeImage.createFromPath(filePath)); + return new KeyframeAnimation(images); + } + + constructor(images: Array<NativeImage>) { + const len = images.length; + if(len < 1) { + throw new Error('too few images in animation'); + } + + this._nativeImages = images.slice(); + this._numFrames = len; + this._frameRange = [0, len]; + } + + get currentImage(): NativeImage { + return this._nativeImages[this._currentFrame]; + } + + play(options: KeyframeAnimationOptions = {}) { + let { startFrame, endFrame, beginFromCurrentState, advanceTo } = options; + + if(startFrame !== undefined && endFrame !== undefined) { + if(startFrame < 0 || startFrame >= this._numFrames) { + throw new Error('Invalid start frame'); + } + + if(endFrame < 0 || endFrame >= this._numFrames) { + throw new Error('Invalid end frame'); + } + + if(startFrame < endFrame) { + this._frameRange = [ startFrame, endFrame ]; + } else { + this._frameRange = [ endFrame, startFrame ]; + } + } else { + this._frameRange = [ 0, this._numFrames - 1 ]; + } + + if(!beginFromCurrentState || this._isFirstRun) { + this._currentFrame = this._frameRange[this._reverse ? 1 : 0]; + } + + if(this._isFirstRun) { + this._isFirstRun = false; + } + + if(advanceTo === 'end') { + this._currentFrame = this._frameRange[this._reverse ? 0 : 1]; + } + + this._isRunning = true; + this._isFinished = false; + + this._unscheduleUpdate(); + + this._render(); + this._scheduleUpdate(); + } + + stop() { + this._isRunning = false; + this._unscheduleUpdate(); + } + + _unscheduleUpdate() { + if(this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + _scheduleUpdate() { + this._timeout = setTimeout(() => this._onUpdateFrame(), this._speed); + } + + _render() { + if(this._onFrame) { + this._onFrame(this._nativeImages[this._currentFrame]); + } + } + + _didFinish() { + this._isFinished = true; + + if(this._onFinish) { + this._onFinish(); + } + } + + _onUpdateFrame() { + this._advanceFrame(); + + if(this._isFinished) { + // mark animation as not running when finished + this._isRunning = false; + } else { + this._render(); + + // check once again since onFrame() may stop animation + if(this._isRunning) { + this._scheduleUpdate(); + } + } + } + + _advanceFrame() { + if(this._isFinished) { return; } + + let lastFrame = this._frameRange[this._reverse ? 0 : 1]; + if(this._currentFrame === lastFrame) { + // mark animation as finished if it's not repeating + if(!this._repeat) { + this._didFinish(); + return; + } + + // change animation direction if marked for alternation + if(this._alternate) { + this._reverse = !this._reverse; + + this._currentFrame = this._nextFrame(this._currentFrame, this._frameRange, this._reverse); + } else { + this._currentFrame = this._frameRange[this._reverse ? 1 : 0]; + } + } else { + this._currentFrame = this._nextFrame(this._currentFrame, this._frameRange, this._reverse); + } + } + + _nextFrame(cur: number, frameRange: KeyframeAnimationRange, isReverse: boolean): number { + if(isReverse) { + if(cur < frameRange[0]) { + return cur + 1; + } else if(cur > frameRange[0]) { + return cur - 1; + } + } else { + if(cur > frameRange[1]) { + return cur - 1; + } else if(cur < frameRange[1]) { + return cur + 1; + } + } + return cur; + } + +} diff --git a/app/lib/platform.android.js b/app/lib/platform.android.js new file mode 100644 index 0000000000..d7bd39b867 --- /dev/null +++ b/app/lib/platform.android.js @@ -0,0 +1,13 @@ +// @flow +import { BackHandler } from 'react-native'; +import { Linking } from 'react-native'; + +const exit = () => { + BackHandler.exitApp(); +}; + +const open = (link: string) => { + Linking.openURL(link); +}; + +export {exit, open}; diff --git a/app/lib/platform.js b/app/lib/platform.js new file mode 100644 index 0000000000..66e5a099ba --- /dev/null +++ b/app/lib/platform.js @@ -0,0 +1,13 @@ +// @flow +import { remote } from 'electron'; +import { shell } from 'electron'; + +const exit = () => { + remote.app.quit(); +}; + +const open = (link: string) => { + shell.openExternal(link); +}; + +export {exit, open}; diff --git a/app/lib/proc.js b/app/lib/proc.js new file mode 100644 index 0000000000..d11fa392a5 --- /dev/null +++ b/app/lib/proc.js @@ -0,0 +1,27 @@ +// @flow + +import path from 'path'; + +export function resolveBin(binaryName: string) { + const basepath = getBasePath(); + return path.resolve(basepath, binaryName + getExtension()); +} + +function getBasePath() { + if (process.env.NODE_ENV === 'development') { + return process.env.MULLVAD_BACKEND || '../talpid_core/target/debug'; + + } else { + return process.resourcesPath; + } +} + +function getExtension() { + switch (process.platform) { + case 'win32': + return '.exe'; + + default: + return ''; + } +} diff --git a/app/lib/relay-settings-builder.js b/app/lib/relay-settings-builder.js new file mode 100644 index 0000000000..4d83956a00 --- /dev/null +++ b/app/lib/relay-settings-builder.js @@ -0,0 +1,195 @@ +// @flow + +import type { + RelayLocation, + RelayProtocol, + RelaySettingsUpdate, + RelaySettingsNormalUpdate, + RelaySettingsCustom +} from './ipc-facade'; + +type LocationBuilder<Self> = { + country: (country: string) => Self, + city: (country: string, city: string) => Self, + any: () => Self, + fromRaw: (location: 'any' | RelayLocation) => Self, +}; + +type OpenVPNConfigurator<Self> = { + port: { + exact: (port: number) => Self, + any: () => Self + }, + protocol: { + exact: (protocol: RelayProtocol) => Self, + any: () => Self + } +}; + +type TunnelBuilder<Self> = { + openvpn: (configurator: (OpenVPNConfigurator<*>) => void) => Self +}; + +class NormalRelaySettingsBuilder { + _payload: RelaySettingsNormalUpdate = {}; + + build(): RelaySettingsUpdate { + return { + normal: this._payload + }; + } + + get location(): LocationBuilder<NormalRelaySettingsBuilder> { + return { + country: (country: string) => { + this._payload.location = { only: { country } }; + return this; + }, + city: (country: string, city: string) => { + this._payload.location = { only: { city: [country, city] } }; + return this; + }, + any: () => { + this._payload.location = 'any'; + return this; + }, + fromRaw: function (location: 'any' | RelayLocation) { + if(location === 'any') { + return this.any(); + } + + if(location.city) { + const [country, city] = location.city; + return this.city(country, city); + } + + if(location.country) { + return this.country(location.country); + } + + throw new Error('Unsupported value of RelayLocation' + + (location && JSON.stringify(location)) ); + }, + }; + } + + get tunnel(): TunnelBuilder<NormalRelaySettingsBuilder> { + const updateOpenvpn = (next) => { + const tunnel = this._payload.tunnel; + if(typeof(tunnel) === 'string' || typeof(tunnel) === 'undefined') { + this._payload.tunnel = { + only: { + openvpn: next + } + }; + } else if(typeof(tunnel) === 'object') { + const prev = (tunnel.only && tunnel.only.openvpn) || {}; + this._payload.tunnel = { + only: { + openvpn: { ...prev, ...next } + } + }; + } + }; + + return { + openvpn: (configurator) => { + const openvpnBuilder = { + get port() { + const apply = (port) => { + updateOpenvpn({ port }); + return this; + }; + return { + exact: (value: number) => apply({ only: value }), + any: () => apply('any'), + }; + }, + get protocol() { + const apply = (protocol) => { + updateOpenvpn({ protocol }); + return this; + }; + return { + exact: (value: RelayProtocol) => apply({ only: value }), + any: () => apply('any'), + }; + } + }; + + configurator(openvpnBuilder); + + return this; + }, + any: () => { + this._payload.tunnel = 'any'; + return this; + } + }; + } + +} + + +type CustomOpenVPNConfigurator<Self> = { + port: (port: number) => Self, + protocol: (protocol: RelayProtocol) => Self +}; + +type CustomTunnelBuilder<Self> = { + openvpn: (configurator: (CustomOpenVPNConfigurator<*>) => void) => Self +}; + +class CustomRelaySettingsBuilder { + _payload: RelaySettingsCustom = { + host: '', + tunnel: { + openvpn: { + port: 0, + protocol: 'udp' + } + } + }; + + build(): RelaySettingsUpdate { + return { + custom_tunnel_endpoint: this._payload + }; + } + + host(value: string) { + this._payload.host = value; + return this; + } + + get tunnel(): CustomTunnelBuilder<CustomRelaySettingsBuilder> { + const updateOpenvpn = (next) => { + const tunnel = this._payload.tunnel || {}; + const prev = tunnel.openvpn || {}; + this._payload.tunnel = { + openvpn: { ...prev, ...next } + }; + }; + + return { + openvpn: (configurator) => { + configurator({ + port: function (port: number) { + updateOpenvpn({ port }); + return this; + }, + protocol: function (protocol: RelayProtocol) { + updateOpenvpn({ protocol }); + return this; + } + }); + return this; + } + }; + } +} + +export default { + normal: () => new NormalRelaySettingsBuilder(), + custom: () => new CustomRelaySettingsBuilder(), +};
\ No newline at end of file diff --git a/app/lib/transition-rule.js b/app/lib/transition-rule.js new file mode 100644 index 0000000000..a91ba4da66 --- /dev/null +++ b/app/lib/transition-rule.js @@ -0,0 +1,47 @@ +// @flow + +export type TransitionDescriptor = { + name: string, + duration: number +}; + +export type TransitionFork = { + forward: TransitionDescriptor, + backward: TransitionDescriptor +}; + +export type TransitionMatch = { + direction: 'forward' | 'backward', + descriptor: TransitionDescriptor +}; + +export default class TransitionRule { + + _from: ?string; + _to: string; + _fork: TransitionFork; + + constructor(from: ?string, to: string, fork: TransitionFork) { + this._from = from; + this._to = to; + this._fork = fork; + } + + match(fromRoute: ?string, toRoute: string): ?TransitionMatch { + if((!this._from || this._from === fromRoute) && this._to === toRoute) { + return { + direction: 'forward', + descriptor: this._fork['forward'] + }; + } + + if((!this._from || this._from === toRoute) && this._to === fromRoute) { + return { + direction: 'backward', + descriptor: this._fork['backward'] + }; + } + + return null; + } +}
\ No newline at end of file diff --git a/app/lib/tray-icon-manager.js b/app/lib/tray-icon-manager.js new file mode 100644 index 0000000000..73424cb7ef --- /dev/null +++ b/app/lib/tray-icon-manager.js @@ -0,0 +1,58 @@ +// @flow +import path from 'path'; +import KeyframeAnimation from './keyframe-animation'; + +import type { Tray } from 'electron'; + +export type TrayIconType = 'unsecured' | 'securing' | 'secured'; + +export default class TrayIconManager { + + _animation: ?KeyframeAnimation; + _iconType: TrayIconType; + + constructor(tray: Tray, initialType: TrayIconType) { + const animation = this._createAnimation(); + animation.onFrame = (img) => tray.setImage(img); + animation.reverse = this._isReverseAnimation(initialType); + animation.play({ advanceTo: 'end' }); + + this._animation = animation; + this._iconType = initialType; + } + + destroy() { + if(this._animation) { + this._animation.stop(); + this._animation = null; + } + } + + _createAnimation(): KeyframeAnimation { + const basePath = path.join(path.resolve(__dirname, '..'), 'assets/images/menubar icons'); + const filePath = path.join(basePath, 'lock-{}.png'); + const animation = KeyframeAnimation.fromFilePattern(filePath, [1, 9]); + animation.speed = 100; + return animation; + } + + _isReverseAnimation(type: TrayIconType): bool { + // unsecured & securing are treated as one + return type !== 'secured'; + } + + get iconType(): TrayIconType { + return this._iconType; + } + + set iconType(type: TrayIconType) { + if(this._iconType === type || !this._animation) { return; } + + const animation = this._animation; + animation.reverse = this._isReverseAnimation(type); + animation.play({ beginFromCurrentState: true }); + + this._iconType = type; + } + +} |
