/* @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 => { // split url into parts const parts: Array = 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 = 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 => ( 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.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.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 => { // get patterns (fields) from routes and sort them by complexity const patterns: Array> = routes .map(r => r.fields) .sort((a, b) => (a.length > b.length ? -1 : 1)); // find pattern const keys: Array = Object.keys(params); let patternToUse: ?Array; let i: number; for (i = 0; i < patterns.length; i++) { const pattern = patterns[i]; const match: Array = 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 => ( 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 => ( dispatch: Dispatch, getState: GetState ): ?string => { let url: ?string; const prefix = `/device/${device.id || device.path}`; if (!device.features) { url = `${prefix}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`; } else if (device.mode === 'bootloader') { // device in bootloader doesn't have device_id url = `${prefix}/bootloader`; } else if (device.mode === 'initialize') { url = `${prefix}/initialize`; } else if (device.mode === 'seedless') { url = `${prefix}/seedless`; } else if (device.firmware === 'required') { url = `${prefix}/firmware-update`; } else if (typeof device.instance === 'number') { url = `${prefix}:${device.instance}`; } else { url = `${prefix}`; // 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 = getState().devices.filter( d => d.path === device.path ); const latest: Array = 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 => ( 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 = 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): Array => 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 => (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.id) { const devUrl: string = `${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.id) return; const devUrl: string = `${selectedDevice.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.id) return; const devUrl: string = `${selectedDevice.id}${ selectedDevice.instance ? `:${selectedDevice.instance}` : '' }`; dispatch(goto(`/device/${devUrl}/backup`)); }; /* * Try to redirect to initial url */ export const setInitialUrl = (): PayloadAction => ( dispatch: Dispatch, getState: GetState ): boolean => { // DEVICE.CONNECT race condition, "selectDevice" method was called but currently selectedDevice is in getState (auth) process // if so, consume this action (return true) to break "selectDevice" process const { selectedDevice } = getState().wallet; if (selectedDevice && selectedDevice.type === 'acquired' && !selectedDevice.state) return true; const { initialPathname } = getState().wallet; if (typeof initialPathname !== 'string') return false; 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; };