diff --git a/app-shell/src/main.js b/app-shell/src/main.js index 4d92aa9aed4..4d348dc847d 100644 --- a/app-shell/src/main.js +++ b/app-shell/src/main.js @@ -43,7 +43,7 @@ function startUp() { // wire modules to UI dispatches const dispatch = action => { - log.debug('Sending action via IPC to renderer', { action }) + log.silly('Sending action via IPC to renderer', { action }) mainWindow.webContents.send('dispatch', action) } diff --git a/app-shell/src/preload.js b/app-shell/src/preload.js index e01b0d11157..f47a87fb4d0 100644 --- a/app-shell/src/preload.js +++ b/app-shell/src/preload.js @@ -3,11 +3,19 @@ // defines subset of Electron API that renderer process is allowed to access // for security reasons import { ipcRenderer, remote } from 'electron' +import cloneDeep from 'lodash/cloneDeep' + +const { getConfig } = remote.require('./config') +const { getRobots } = remote.require('./discovery') +const { CURRENT_VERSION, CURRENT_RELEASE_NOTES } = remote.require('./update') global.APP_SHELL_REMOTE = { ipcRenderer, + CURRENT_VERSION, + CURRENT_RELEASE_NOTES, + INITIAL_CONFIG: cloneDeep(getConfig()), + INITIAL_ROBOTS: getRobots(), + + // TODO(mc, 2019-08-05): remove when we remove __buildrootEnabled FF apiUpdate: remote.require('./api-update'), - config: remote.require('./config'), - discovery: remote.require('./discovery'), - update: remote.require('./update'), } diff --git a/app/src/config/__tests__/config.test.js b/app/src/config/__tests__/config.test.js index db4414e2cf6..0d588afa4f6 100644 --- a/app/src/config/__tests__/config.test.js +++ b/app/src/config/__tests__/config.test.js @@ -1,9 +1,7 @@ // config tests import { updateConfig, configReducer, getConfig } from '..' -import * as mockShell from '../../shell' - -jest.mock('../../shell', () => ({ getShellConfig: jest.fn() })) +jest.mock('../../shell/remote', () => ({ INITIAL_CONFIG: { isConfig: true } })) describe('config', () => { let state @@ -36,8 +34,6 @@ describe('config', () => { }) test('gets store and overrides from remote for initial state', () => { - mockShell.getShellConfig.mockReturnValue({ isConfig: true }) - expect(configReducer(null, {})).toEqual({ isConfig: true }) }) diff --git a/app/src/config/index.js b/app/src/config/index.js index e4fbd424998..65d7c945e82 100644 --- a/app/src/config/index.js +++ b/app/src/config/index.js @@ -3,104 +3,12 @@ import { setIn } from '@thi.ng/paths' import remove from 'lodash/remove' -import { getShellConfig } from '../shell' +import remote from '../shell/remote' import type { State, Action, ThunkAction } from '../types' -import type { LogLevel } from '../logger' +import type { Config, UpdateConfigAction } from './types' -type UrlProtocol = 'file:' | 'http:' - -export type UpdateChannel = 'latest' | 'beta' | 'alpha' - -export type DiscoveryCandidates = string | Array - -// TODO(mc, 2018-05-17): put this type somewhere common to app and app-shell -export type Config = { - devtools: boolean, - - // app update config - update: { - channel: UpdateChannel, - }, - - // robot update config - buildroot: { - manifestUrl: string, - }, - - // logging config - log: { - level: { - file: LogLevel, - console: LogLevel, - }, - }, - - // ui and browser config - ui: { - width: number, - height: number, - url: { - protocol: UrlProtocol, - path: string, - }, - webPreferences: { - webSecurity: boolean, - }, - }, - - analytics: { - appId: string, - optedIn: boolean, - seenOptIn: boolean, - }, - - // deprecated; remove with first migration - p10WarningSeen: { - [id: string]: ?boolean, - }, - - support: { - userId: string, - createdAt: number, - name: string, - email: ?string, - }, - - discovery: { - candidates: DiscoveryCandidates, - }, - - // internal development flags - devInternal?: { - allPipetteConfig?: boolean, - tempdeckControls?: boolean, - enableThermocycler?: boolean, - enablePipettePlus?: boolean, - enableBuildRoot?: boolean, - }, -} - -type UpdateConfigAction = {| - type: 'config:UPDATE', - payload: {| - path: string, - value: any, - |}, - meta: {| - shell: true, - |}, -|} - -type SetConfigAction = {| - type: 'config:SET', - payload: {| - path: string, - value: any, - |}, -|} - -export type ConfigAction = UpdateConfigAction | SetConfigAction +export * from './types' // trigger a config value update to the app-shell via shell middleware export function updateConfig(path: string, value: any): UpdateConfigAction { @@ -113,9 +21,8 @@ export function updateConfig(path: string, value: any): UpdateConfigAction { // config reducer export function configReducer(state: ?Config, action: Action): Config { - // initial state - // getShellConfig makes a sync RPC call, so use sparingly - if (!state) return getShellConfig() + // initial state from app-shell preloaded remote + if (!state) return remote.INITIAL_CONFIG switch (action.type) { case 'config:SET': @@ -142,6 +49,7 @@ export function addManualIp(ip: string): ThunkAction { const previous: ?string = [].concat(candidates).find(i => i === ip) let nextCandidatesList = candidates if (!previous) nextCandidatesList = nextCandidatesList.concat(ip) + return dispatch(updateConfig('discovery.candidates', nextCandidatesList)) } } @@ -149,9 +57,9 @@ export function addManualIp(ip: string): ThunkAction { export function removeManualIp(ip: string): ThunkAction { return (dispatch, getState) => { const candidates = [].concat(getConfig(getState()).discovery.candidates) - remove(candidates, c => { - return c === ip - }) + + remove(candidates, c => c === ip) + return dispatch(updateConfig('discovery.candidates', candidates)) } } diff --git a/app/src/config/types.js b/app/src/config/types.js new file mode 100644 index 00000000000..14a6d6784c7 --- /dev/null +++ b/app/src/config/types.js @@ -0,0 +1,95 @@ +// @flow +import type { LogLevel } from '../logger' + +export type UrlProtocol = 'file:' | 'http:' + +export type UpdateChannel = 'latest' | 'beta' | 'alpha' + +export type DiscoveryCandidates = string | Array + +export type Config = { + devtools: boolean, + + // app update config + update: { + channel: UpdateChannel, + }, + + // robot update config + buildroot: { + manifestUrl: string, + }, + + // logging config + log: { + level: { + file: LogLevel, + console: LogLevel, + }, + }, + + // ui and browser config + ui: { + width: number, + height: number, + url: { + protocol: UrlProtocol, + path: string, + }, + webPreferences: { + webSecurity: boolean, + }, + }, + + analytics: { + appId: string, + optedIn: boolean, + seenOptIn: boolean, + }, + + // deprecated; remove with first migration + p10WarningSeen: { + [id: string]: ?boolean, + }, + + support: { + userId: string, + createdAt: number, + name: string, + email: ?string, + }, + + discovery: { + candidates: DiscoveryCandidates, + }, + + // internal development flags + devInternal?: { + allPipetteConfig?: boolean, + tempdeckControls?: boolean, + enableThermocycler?: boolean, + enablePipettePlus?: boolean, + enableBuildRoot?: boolean, + }, +} + +export type UpdateConfigAction = {| + type: 'config:UPDATE', + payload: {| + path: string, + value: any, + |}, + meta: {| + shell: true, + |}, +|} + +export type SetConfigAction = {| + type: 'config:SET', + payload: {| + path: string, + value: any, + |}, +|} + +export type ConfigAction = UpdateConfigAction | SetConfigAction diff --git a/app/src/discovery/__tests__/reducer.test.js b/app/src/discovery/__tests__/reducer.test.js index 7b9fceca24a..7d315330c98 100644 --- a/app/src/discovery/__tests__/reducer.test.js +++ b/app/src/discovery/__tests__/reducer.test.js @@ -1,8 +1,8 @@ // discovery reducer test import { discoveryReducer } from '..' -jest.mock('../../shell', () => ({ - getShellRobots: () => [ +jest.mock('../../shell/remote', () => ({ + INITIAL_ROBOTS: [ { name: 'foo', ip: '192.168.1.1', port: 31950 }, { name: 'bar', ip: '192.168.1.2', port: 31950 }, ], diff --git a/app/src/discovery/index.js b/app/src/discovery/index.js index 5d5ca318a8d..d8e4545af74 100644 --- a/app/src/discovery/index.js +++ b/app/src/discovery/index.js @@ -4,7 +4,7 @@ import groupBy from 'lodash/groupBy' import mapValues from 'lodash/mapValues' import some from 'lodash/some' -import { getShellRobots } from '../shell' +import remote from '../shell/remote' import * as actions from './actions' import type { Service } from '@opentrons/discovery-client' @@ -29,15 +29,14 @@ export type DiscoveryState = {| export const RESTART_PENDING: RestartStatus = 'pending' export const RESTART_DOWN: RestartStatus = 'down' -// getShellRobots makes a sync RPC call, so use sparingly -const initialState: DiscoveryState = { +const INITIAL_STATE: DiscoveryState = { scanning: false, - robotsByName: normalizeRobots(getShellRobots()), restartsByName: {}, + robotsByName: normalizeRobots(remote.INITIAL_ROBOTS), } export function discoveryReducer( - state: DiscoveryState = initialState, + state: DiscoveryState = INITIAL_STATE, action: Action ): DiscoveryState { switch (action.type) { diff --git a/app/src/epic.js b/app/src/epic.js index aa91b664da8..ed93fa3c81c 100644 --- a/app/src/epic.js +++ b/app/src/epic.js @@ -2,8 +2,8 @@ // root application epic import { combineEpics } from 'redux-observable' -import { buildrootUpdateEpic } from './shell' import { discoveryEpic } from './discovery' import { robotApiEpic } from './robot-api' +import { shellEpic } from './shell' -export default combineEpics(buildrootUpdateEpic, discoveryEpic, robotApiEpic) +export default combineEpics(discoveryEpic, robotApiEpic, shellEpic) diff --git a/app/src/index.js b/app/src/index.js index b4f3a5f002c..b800cfc6b93 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -10,7 +10,7 @@ import { ConnectedRouter, routerMiddleware } from 'connected-react-router' import { createEpicMiddleware } from 'redux-observable' import createLogger from './logger' -import { checkShellUpdate, shellMiddleware } from './shell' +import { checkShellUpdate } from './shell' import { apiClientMiddleware as robotApiMiddleware } from './robot' import { initializeAnalytics, analyticsMiddleware } from './analytics' @@ -31,7 +31,6 @@ const middleware = applyMiddleware( thunk, epicMiddleware, robotApiMiddleware(), - shellMiddleware, analyticsMiddleware, supportMiddleware, routerMiddleware(history) diff --git a/app/src/shell/__mocks__/remote.js b/app/src/shell/__mocks__/remote.js index 5a56b3d4ad4..2d49b738202 100644 --- a/app/src/shell/__mocks__/remote.js +++ b/app/src/shell/__mocks__/remote.js @@ -2,23 +2,20 @@ // keep in sync with app-shell/src/preload.js 'use strict' +const EventEmitter = require('events') + +class MockIpcRenderer extends EventEmitter { + send = jest.fn() +} + module.exports = { - ipcRenderer: { - on: jest.fn(), - send: jest.fn(), - }, + ipcRenderer: new MockIpcRenderer(), apiUpdate: { getUpdateInfo: jest.fn(), getUpdateFileContents: jest.fn(), }, - config: { - getConfig: jest.fn(), - }, - discovery: { - getRobots: jest.fn(), - }, - update: { - CURRENT_VERSION: '0.0.0', - CURRENT_RELEASE_NOTES: 'Release notes for 0.0.0', - }, + CURRENT_VERSION: '0.0.0', + CURRENT_RELEASE_NOTES: 'Release notes for 0.0.0', + INITIAL_CONFIG: {}, + INITIAL_ROBOTS: [], } diff --git a/app/src/shell/__tests__/epics.test.js b/app/src/shell/__tests__/epics.test.js new file mode 100644 index 00000000000..e79e72b3474 --- /dev/null +++ b/app/src/shell/__tests__/epics.test.js @@ -0,0 +1,50 @@ +// tests for the shell module +import { TestScheduler } from 'rxjs/testing' +import { take } from 'rxjs/operators' + +import mockRemote from '../remote' +import { sendActionToShellEpic, receiveActionFromShellEpic } from '..' + +const { ipcRenderer: mockIpc } = mockRemote + +describe('shell epics', () => { + let testScheduler + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + test('sendActionToShellEpic "dispatches" actions to IPC if meta.shell', () => { + const shellAction = { type: 'foo', meta: { shell: true } } + + testScheduler.run(({ hot, expectObservable }) => { + const action$ = hot('-a', { a: shellAction }) + const output$ = sendActionToShellEpic(action$) + + expectObservable(output$).toBe('--') + }) + + // NOTE: this expectation has to outside the testScheduler scope or else + // everything breaks for some reason + expect(mockIpc.send).toHaveBeenCalledWith('dispatch', shellAction) + }) + + // due to the use of `fromEvent`, this test doesn't work well as a marble + // test. `toPromise` based expectation should be sufficient + test('catches actions from main', () => { + const shellAction = { type: 'bar' } + const result = receiveActionFromShellEpic() + .pipe(take(1)) + .toPromise() + + mockIpc.emit('dispatch', {}, shellAction) + + return expect(result).resolves.toEqual(shellAction) + }) +}) diff --git a/app/src/shell/__tests__/shell.test.js b/app/src/shell/__tests__/shell.test.js deleted file mode 100644 index fc3770cc90a..00000000000 --- a/app/src/shell/__tests__/shell.test.js +++ /dev/null @@ -1,47 +0,0 @@ -// tests for the shell module -import configureMockStore from 'redux-mock-store' -import thunk from 'redux-thunk' - -import mockRemote from '../remote' -import { shellMiddleware, getShellConfig } from '..' - -const middlewares = [thunk, shellMiddleware] -const mockStore = configureMockStore(middlewares) -const { ipcRenderer: mockIpc, config: mockConfig } = mockRemote - -describe('app shell module', () => { - afterEach(() => { - jest.clearAllMocks() - }) - - describe('shell middleware', () => { - test('"dispatches" actions to the app shell if meta.shell', () => { - const store = mockStore({}) - const action = { type: 'foo', meta: { shell: true } } - - store.dispatch(action) - expect(mockIpc.send).toHaveBeenCalledWith('dispatch', action) - }) - - test('catches actions from main and dispatches them to redux', () => { - const store = mockStore({}) - const action = { type: 'foo' } - - expect(mockIpc.on).toHaveBeenCalledWith('dispatch', expect.any(Function)) - - const dispatchHandler = mockIpc.on.mock.calls.find(call => { - return call[0] === 'dispatch' && typeof call[1] === 'function' - })[1] - - dispatchHandler({}, action) - expect(store.getActions()).toEqual([action]) - }) - }) - - describe('config remote', () => { - test('getShellConfig', () => { - mockConfig.getConfig.mockReturnValue({ isConfig: true }) - expect(getShellConfig()).toEqual({ isConfig: true }) - }) - }) -}) diff --git a/app/src/shell/api-update.js b/app/src/shell/api-update.js index 3c9da917bf8..cb1cae0d610 100644 --- a/app/src/shell/api-update.js +++ b/app/src/shell/api-update.js @@ -8,8 +8,8 @@ export type ApiUpdateInfo = { } const { + CURRENT_VERSION, apiUpdate: { getUpdateInfo, getUpdateFileContents }, - update: { CURRENT_VERSION }, } = remote export function apiUpdateReducer(state: ?ApiUpdateInfo): ApiUpdateInfo { diff --git a/app/src/shell/buildroot/selectors.js b/app/src/shell/buildroot/selectors.js index 2a10d9acae0..c495509b414 100644 --- a/app/src/shell/buildroot/selectors.js +++ b/app/src/shell/buildroot/selectors.js @@ -112,7 +112,7 @@ export function compareRobotVersionToUpdate( ): BuildrootUpdateType { const currentVersion = getRobotApiVersion(robot) // TODO(mc, 2019-07-23): get this from state once BR state info can come in piecemeal - const updateVersion: string = remote.update.CURRENT_VERSION + const updateVersion: string = remote.CURRENT_VERSION const validCurrent: string | null = semver.valid(currentVersion) const validUpdate: string | null = semver.valid(updateVersion) diff --git a/app/src/shell/index.js b/app/src/shell/index.js index 7b4636f5d3d..5d9c7c8213b 100644 --- a/app/src/shell/index.js +++ b/app/src/shell/index.js @@ -1,59 +1,41 @@ // @flow // desktop shell module import { combineReducers } from 'redux' +import { combineEpics } from 'redux-observable' +import { fromEvent } from 'rxjs' +import { filter, tap, ignoreElements } from 'rxjs/operators' import createLogger from '../logger' import remote from './remote' import { updateReducer } from './update' import { apiUpdateReducer } from './api-update' -import { buildrootReducer } from './buildroot' +import { buildrootReducer, buildrootUpdateEpic } from './buildroot' import type { Reducer } from 'redux' -import type { Service } from '@opentrons/discovery-client' + import type { - Middleware, + LooseEpic, ThunkAction, Action, + ActionLike, Dispatch, GetState, } from '../types' + import type { ViewableRobot } from '../discovery' -import type { Config } from '../config' -import type { ApiUpdateInfo as ApiUpdateState } from './api-update' -import type { ShellUpdateState, ShellUpdateAction } from './update' -import type { BuildrootState, BuildrootAction } from './buildroot' - -type ShellLogsDownloadAction = {| - type: 'shell:DOWNLOAD_LOGS', - payload: {| logUrls: Array |}, - meta: {| shell: true |}, -|} - -export type ShellState = {| - update: ShellUpdateState, - apiUpdate: ApiUpdateState, - buildroot: BuildrootState, -|} - -export type ShellAction = - | ShellUpdateAction - | ShellLogsDownloadAction - | BuildrootAction - -const { - ipcRenderer, - config: { getConfig }, - discovery: { getRobots }, -} = remote +import type { ShellState } from './types' + +const { ipcRenderer } = remote const log = createLogger(__filename) export * from './update' export * from './api-update' export * from './buildroot' +export * from './types' -const CURRENT_VERSION: string = remote.update.CURRENT_VERSION -const CURRENT_RELEASE_NOTES: string = remote.update.CURRENT_RELEASE_NOTES +const CURRENT_VERSION: string = remote.CURRENT_VERSION +const CURRENT_RELEASE_NOTES: string = remote.CURRENT_RELEASE_NOTES const API_RELEASE_NOTES = CURRENT_RELEASE_NOTES.replace( /([\S\s]*?)/, '' @@ -70,30 +52,35 @@ export const shellReducer: Reducer = combineReducers< buildroot: buildrootReducer, }) -export const shellMiddleware: Middleware = store => { - const { dispatch } = store - - ipcRenderer.on('dispatch', (_, action) => { - log.debug('Received action from main via IPC', { action }) - dispatch(action) - }) - - return next => action => { - if (action.meta && action.meta.shell) ipcRenderer.send('dispatch', action) - - return next(action) - } -} - -// getShellConfig makes a sync RPC call, so use sparingly -export function getShellConfig(): Config { - return getConfig() -} +export const sendActionToShellEpic: LooseEpic = action$ => + action$.pipe( + filter((action: ActionLike) => action.meta?.shell === true), + tap((shellAction: ActionLike) => + ipcRenderer.send('dispatch', shellAction) + ), + ignoreElements() + ) + +export const receiveActionFromShellEpic = () => + // IPC event listener: (IpcRendererEvent, ...args) => void + // our action is the only argument, so pluck it out from index 1 + fromEvent( + ipcRenderer, + 'dispatch', + (_: mixed, incoming: ActionLike) => incoming + ).pipe( + tap(incoming => { + log.debug('Received action from main via IPC', { + actionType: incoming.type, + }) + }) + ) -// getShellRobots makes a sync RPC call, so use sparingly -export function getShellRobots(): Array { - return getRobots() -} +export const shellEpic = combineEpics( + sendActionToShellEpic, + receiveActionFromShellEpic, + buildrootUpdateEpic +) export function downloadLogs(robot: ViewableRobot): ThunkAction { return (dispatch: Dispatch, getState: GetState) => { diff --git a/app/src/shell/types.js b/app/src/shell/types.js new file mode 100644 index 00000000000..75777aa0464 --- /dev/null +++ b/app/src/shell/types.js @@ -0,0 +1,21 @@ +// @flow +import type { ApiUpdateInfo as ApiUpdateState } from './api-update' +import type { BuildrootState, BuildrootAction } from './buildroot/types' +import type { ShellUpdateState, ShellUpdateAction } from './update' + +export type ShellState = {| + update: ShellUpdateState, + apiUpdate: ApiUpdateState, + buildroot: BuildrootState, +|} + +export type ShellLogsDownloadAction = {| + type: 'shell:DOWNLOAD_LOGS', + payload: {| logUrls: Array |}, + meta: {| shell: true |}, +|} + +export type ShellAction = + | ShellUpdateAction + | ShellLogsDownloadAction + | BuildrootAction