You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
trezor-wallet/src/actions/RouterActions.js

459 lines
15 KiB

/* @flow */
import { push, LOCATION_CHANGE } from 'connected-react-router';
import { CONTEXT_NONE } from 'actions/constants/modal';
import { SET_INITIAL_URL, SET_FIRST_LOCATION_CHANGE } from 'actions/constants/wallet';
import { routes } from 'support/routes';
import * as deviceUtils from 'utils/device';
import type {
RouterLocationState,
Device,
TrezorDevice,
ThunkAction,
PayloadAction,
Dispatch,
GetState,
} from 'flowtype';
import type { RouterAction } from 'connected-react-router';
/*
* Parse url string to RouterLocationState object (key/value)
*/
export const pathToParams = (
path: string
): PayloadAction<RouterLocationState> => (): RouterLocationState => {
// split url into parts
const parts: Array<string> = path.split('/').slice(1);
const params: RouterLocationState = {};
// return empty params
if (parts.length < 1 || path === '/') return params;
// map parts to params by key/value
// assuming that url is in format: "/key/value"
for (let i = 0, len = parts.length; i < len; i += 2) {
params[parts[i]] = parts[i + 1] || parts[i];
}
// check for special case: /device/device-id:instance-id
if (params.hasOwnProperty('device')) {
const isClonedDevice: Array<string> = params.device.split(':');
if (isClonedDevice.length > 1) {
const [device, instance] = isClonedDevice;
params.device = device;
params.deviceInstance = instance;
}
}
return params;
};
/*
* RouterLocationState validation
* Check if requested device or network exists in reducers
*/
export const paramsValidation = (params: RouterLocationState): PayloadAction<boolean> => (
dispatch: Dispatch,
getState: GetState
): boolean => {
// validate requested device
if (params.hasOwnProperty('device')) {
const { devices } = getState();
let device: ?TrezorDevice;
if (params.hasOwnProperty('deviceInstance')) {
device = devices.find(
d =>
d.features &&
d.features.device_id === params.device &&
d.instance === parseInt(params.deviceInstance, 10)
);
} else {
device = devices.find(
d =>
((!d.features || d.mode === 'bootloader') && d.path === params.device) ||
(d.features && d.features.device_id === params.device)
);
}
if (!device) return false;
if (!deviceUtils.isDeviceAccessible(device)) {
// no access to deep links if device has incorrect mode/firmware
if (params.hasOwnProperty('network') || params.hasOwnProperty('account')) return false;
}
}
// validate requested network
if (params.hasOwnProperty('network')) {
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === params.network);
if (!network) return false;
if (!params.account) return false;
}
// validate requested account
// TODO: only if discovery on this network is completed
// if (params.hasOwnProperty('account')) {
// }
return true;
};
/*
* Composing url string from given RouterLocationState object
* Filters unrecognized fields and sorting in correct order
*/
export const paramsToPath = (
params: RouterLocationState
): PayloadAction<?string> => (): ?string => {
// get patterns (fields) from routes and sort them by complexity
const patterns: Array<Array<string>> = routes
.map(r => r.fields)
.sort((a, b) => (a.length > b.length ? -1 : 1));
// find pattern
const keys: Array<string> = Object.keys(params);
let patternToUse: ?Array<string>;
let i: number;
for (i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
const match: Array<string> = keys.filter(key => pattern.indexOf(key) >= 0);
if (match.length === pattern.length) {
patternToUse = pattern;
break;
}
}
// pattern not found, redirect back
if (!patternToUse) return null;
// compose url string from pattern
let url: string = '';
patternToUse.forEach(field => {
if (field === params[field]) {
// standalone (odd) fields
url += `/${field}`;
} else {
url += `/${field}/${params[field]}`;
if (field === 'device') {
if (params.hasOwnProperty('deviceInstance')) {
url += `:${params.deviceInstance}`;
}
}
}
});
return url;
};
export const getValidUrl = (action: RouterAction): PayloadAction<string> => (
dispatch: Dispatch,
getState: GetState
): string => {
const { location } = getState().router;
const { firstLocationChange } = getState().wallet;
// redirect to landing page (loading screen)
// and wait until application is ready
if (firstLocationChange) {
dispatch({
type: SET_FIRST_LOCATION_CHANGE,
});
return '/';
}
const requestedUrl = action.payload.location.pathname;
// Corner case: LOCATION_CHANGE was called but pathname didn't changed (redirect action from RouterService)
if (requestedUrl === location.pathname) return requestedUrl;
// Modal is opened
// redirect to previous url
if (getState().modal.context !== CONTEXT_NONE) {
// Corner case: modal is opened and currentParams are still valid
// example 1 (valid blocking): url changed while passphrase modal opened but device is still connected (we want user to finish this action)
// example 2 (invalid blocking): url changes while passphrase modal opened because device disconnect
const currentParams = dispatch(pathToParams(location.pathname));
const currentParamsAreValid = dispatch(paramsValidation(currentParams));
if (currentParamsAreValid) {
return location.pathname;
}
}
// there are no connected devices or application isn't ready or initialization error occurred
// redirect to landing page
const shouldBeLandingPage =
getState().devices.length < 1 ||
!getState().wallet.ready ||
getState().connect.error !== null;
const landingPageUrl = dispatch(isLandingPageUrl(requestedUrl));
if (shouldBeLandingPage) {
const landingPageRoute = dispatch(isLandingPageUrl(requestedUrl, getState().wallet.ready));
return !landingPageRoute ? '/' : requestedUrl;
}
// Disallow displaying landing page
// redirect to previous url
if (!shouldBeLandingPage && landingPageUrl) {
return dispatch(getFirstAvailableDeviceUrl()) || location.pathname;
}
// Regular url change during application live cycle
const requestedParams = dispatch(pathToParams(requestedUrl));
const requestedParamsAreValid: boolean = dispatch(paramsValidation(requestedParams));
// Requested params are not valid
// Neither device or network doesn't exists
if (!requestedParamsAreValid) {
return location.pathname;
}
// Compose valid url from requested params
const composedUrl = dispatch(paramsToPath(requestedParams));
return composedUrl || location.pathname;
};
/*
* Compose url from requested device object and returns url
*/
const getDeviceUrl = (device: TrezorDevice | Device): PayloadAction<?string> => (
dispatch: Dispatch,
getState: GetState
): ?string => {
let url: ?string;
if (!device.features) {
url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`;
} else if (device.mode === 'bootloader') {
// device in bootloader doesn't have device_id
url = `/device/${device.path}/bootloader`;
} else if (device.mode === 'initialize') {
url = `/device/${device.features.device_id}/initialize`;
} else if (device.mode === 'seedless') {
url = `/device/${device.features.device_id}/seedless`;
} else if (device.firmware === 'required') {
url = `/device/${device.features.device_id}/firmware-update`;
} else if (typeof device.instance === 'number') {
url = `/device/${device.features.device_id}:${device.instance}`;
} else {
url = `/device/${device.features.device_id}`;
// make sure that device is not TrezorDevice type
if (!device.hasOwnProperty('ts')) {
// it is device from trezor-connect triggered by DEVICE.CONNECT event
// need to lookup if there are unavailable instances
const available: Array<TrezorDevice> = getState().devices.filter(
d => d.path === device.path
);
const latest: Array<TrezorDevice> = sortDevices(available);
if (latest.length > 0 && latest[0].instance) {
url += `:${latest[0].instance}`;
}
}
}
return url;
};
/*
* Try to find first available device using order:
* 1. First unacquired
* 2. First connected
* 3. Saved with latest timestamp
* OR redirect to landing page
*/
export const getFirstAvailableDeviceUrl = (): PayloadAction<?string> => (
dispatch: Dispatch,
getState: GetState
): ?string => {
const { devices } = getState();
let url: ?string;
if (devices.length > 0) {
const unacquired = devices.find(d => !d.features);
if (unacquired) {
url = dispatch(getDeviceUrl(unacquired));
} else {
const latest: Array<TrezorDevice> = sortDevices(devices);
const firstConnected: ?TrezorDevice = latest.find(d => d.connected);
url = dispatch(getDeviceUrl(firstConnected || latest[0]));
}
}
return url;
};
/*
* Utility used in "getDeviceUrl" and "getFirstAvailableDeviceUrl"
* sorting device array by "ts" (timestamp) field
*/
const sortDevices = (devices: Array<TrezorDevice>): Array<TrezorDevice> =>
devices.sort((a, b) => {
if (!a.ts || !b.ts) {
return -1;
}
return a.ts > b.ts ? -1 : 1;
});
/*
* Redirect to requested device
*/
export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (
dispatch: Dispatch,
getState: GetState
): void => {
if (dispatch(setInitialUrl())) return;
const url: ?string = dispatch(getDeviceUrl(device));
if (!url) return;
const currentParams: RouterLocationState = getState().router.location.state;
const requestedParams = dispatch(pathToParams(url));
if (
currentParams.device !== requestedParams.device ||
currentParams.deviceInstance !== requestedParams.deviceInstance
) {
dispatch(goto(url));
}
};
/*
* Redirect to first device or landing page
*/
export const selectFirstAvailableDevice = (gotoRoot: boolean = false): ThunkAction => (
dispatch: Dispatch,
getState: GetState
): void => {
const url = dispatch(getFirstAvailableDeviceUrl());
if (url) {
const currentParams = getState().router.location.state;
const requestedParams = dispatch(pathToParams(url));
if (
gotoRoot ||
currentParams.device !== requestedParams.device ||
currentParams.deviceInstance !== requestedParams.deviceInstance
) {
dispatch(goto(url));
}
} else {
dispatch(gotoLandingPage());
}
};
/*
* Internal method. redirect to given url
*/
const goto = (url: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
if (getState().router.location.pathname !== url) {
dispatch(push(url));
}
};
/*
* Check if requested OR current url is landing page
*/
export const isLandingPageUrl = (
$url?: string,
checkRoutes: boolean = false
): PayloadAction<boolean> => (dispatch: Dispatch, getState: GetState): boolean => {
let url: ?string = $url;
if (typeof url !== 'string') {
url = getState().router.location.pathname;
}
if (checkRoutes) {
const isLandingRoute = routes.find(
r => r.pattern === url && r.name.indexOf('landing') >= 0
);
if (isLandingRoute) {
return true;
}
}
return url === '/';
};
/*
* Try to redirect to landing page
*/
export const gotoLandingPage = (): ThunkAction => (dispatch: Dispatch): void => {
const isLandingPage = dispatch(isLandingPageUrl());
if (!isLandingPage) {
dispatch(goto('/'));
}
};
/*
* Go to given device settings page
*/
export const gotoDeviceSettings = (device: TrezorDevice): ThunkAction => (
dispatch: Dispatch
): void => {
if (device.features) {
const devUrl: string = `${device.features.device_id}${
device.instance ? `:${device.instance}` : ''
}`;
dispatch(goto(`/device/${devUrl}/settings`));
}
};
/*
* Go to UpdateBridge page
*/
export const gotoBridgeUpdate = (): ThunkAction => (dispatch: Dispatch): void => {
dispatch(goto('/bridge'));
};
/*
* Go to UpdateFirmware page
* Called from App notification
*/
export const gotoFirmwareUpdate = (): ThunkAction => (
dispatch: Dispatch,
getState: GetState
): void => {
const { selectedDevice } = getState().wallet;
if (!selectedDevice || !selectedDevice.features) return;
const devUrl: string = `${selectedDevice.features.device_id}${
selectedDevice.instance ? `:${selectedDevice.instance}` : ''
}`;
dispatch(goto(`/device/${devUrl}/firmware-update`));
};
/*
* Go to NoBackup page
*/
export const gotoBackup = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const { selectedDevice } = getState().wallet;
if (!selectedDevice || !selectedDevice.features) return;
const devUrl: string = `${selectedDevice.features.device_id}${
selectedDevice.instance ? `:${selectedDevice.instance}` : ''
}`;
dispatch(goto(`/device/${devUrl}/backup`));
};
/*
* Try to redirect to initial url
*/
export const setInitialUrl = (): PayloadAction<boolean> => (
dispatch: Dispatch,
getState: GetState
): boolean => {
const { initialPathname } = getState().wallet;
if (typeof initialPathname === 'string' && !dispatch(isLandingPageUrl(initialPathname, true))) {
const valid = dispatch(
getValidUrl({
type: LOCATION_CHANGE,
payload: {
location: {
pathname: initialPathname,
hash: '',
search: '',
state: {},
},
},
})
);
if (valid === initialPathname) {
// reset initial url
dispatch({
type: SET_INITIAL_URL,
});
dispatch(goto(valid));
return true;
}
}
return false;
};