summaryrefslogtreecommitdiffhomepage
path: root/app/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/lib')
-rw-r--r--app/lib/backend.js476
-rw-r--r--app/lib/formatters.js9
-rw-r--r--app/lib/ipc-facade.js333
-rw-r--r--app/lib/jsonrpc-ws-ipc.js290
-rw-r--r--app/lib/keyframe-animation.js226
-rw-r--r--app/lib/platform.android.js13
-rw-r--r--app/lib/platform.js13
-rw-r--r--app/lib/proc.js27
-rw-r--r--app/lib/relay-settings-builder.js195
-rw-r--r--app/lib/transition-rule.js47
-rw-r--r--app/lib/tray-icon-manager.js58
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;
+ }
+
+}