diff --git a/src/js/electron/brim-boot.test.ts b/src/js/electron/brim-boot.test.ts new file mode 100644 index 0000000000..8015c3a0c1 --- /dev/null +++ b/src/js/electron/brim-boot.test.ts @@ -0,0 +1,32 @@ +import {Brim} from "./brim" +import path from "path" + +const file = `tmp-boot-test/appState.json` + +test("boot starts zqd with defaults", async () => { + const createSession = () => ({ + load: () => Promise.resolve(undefined) + }) + // @ts-ignore + const brim = await Brim.boot(file, createSession) + expect(brim.zqd.root).toBe(path.normalize("/fake/path/data/spaces")) + expect(brim.zqd.suricataRunner).toBe("") + expect(brim.zqd.suricataUpdater).toBe("") + expect(brim.zqd.zeekRunner).toBe("") +}) + +test("gets global state from store", async () => { + const createSession = () => ({ + load: () => + Promise.resolve({ + globalState: { + prefs: { + zeekRunner: "testing123" + } + } + }) + }) + // @ts-ignore + const brim = await Brim.boot(path, createSession) + expect(brim.store.getState().prefs.zeekRunner).toEqual("testing123") +}) diff --git a/src/js/electron/brim-quit.test.ts b/src/js/electron/brim-quit.test.ts new file mode 100644 index 0000000000..9c2d7b4e4f --- /dev/null +++ b/src/js/electron/brim-quit.test.ts @@ -0,0 +1,51 @@ +import {Brim} from "./brim" +import {app, ipcMain} from "electron" + +function mockIpc(response) { + jest + .spyOn(ipcMain, "once") + // @ts-ignore + .mockImplementationOnce((_channel: string, listener) => + listener(null, response) + ) +} + +let brim: Brim +beforeEach(async () => { + // @ts-ignore + app.quit.mockClear() + brim = new Brim() + jest.spyOn(brim.zqd, "start").mockImplementation(() => {}) + jest.spyOn(brim.zqd, "close").mockImplementation(() => Promise.resolve()) + jest + .spyOn(brim.session, "load") + // @ts-ignore + .mockImplementation(() => Promise.resolve("fake-session-state")) + jest.spyOn(brim.session, "save").mockImplementation(() => Promise.resolve()) + await brim.start() +}) + +test("quit and save", async () => { + mockIpc(true) // the confirm ipc + mockIpc(null) // the prepare ipc + mockIpc({state: "test"}) // the update window state ipc + + await brim.quit() + + expect(brim.isQuitting).toBe(true) + expect(brim.session.save).toHaveBeenCalledTimes(1) + expect(brim.zqd.close).toHaveBeenCalledTimes(1) + expect(app.quit).toHaveBeenCalledTimes(1) +}) + +test("quit without saving", async () => { + mockIpc(true) // confirm + mockIpc(null) // prepare + + await brim.quit({saveSession: false}) + + expect(brim.isQuitting).toBe(true) + expect(brim.session.save).not.toHaveBeenCalled() + expect(brim.zqd.close).toHaveBeenCalledTimes(1) + expect(app.quit).toHaveBeenCalledTimes(1) +}) diff --git a/src/js/electron/brim-reset-state.test.ts b/src/js/electron/brim-reset-state.test.ts new file mode 100644 index 0000000000..e98c07c072 --- /dev/null +++ b/src/js/electron/brim-reset-state.test.ts @@ -0,0 +1,47 @@ +import {app} from "electron" +import {ZQD} from "ppl/zqd/zqd" +import {Brim} from "./brim" +import tron from "./tron" +import windowManager from "./tron/windowManager" + +function mockZqd() { + const zqd = new ZQD("test", "srun", "supdate", "zrun") + jest.spyOn(zqd, "start").mockImplementation(() => {}) + jest.spyOn(zqd, "close").mockImplementation(() => Promise.resolve()) + return zqd +} + +function mockSession() { + const session = tron.session() + jest.spyOn(session, "delete").mockImplementation(() => Promise.resolve()) + jest.spyOn(session, "save").mockImplementation(() => Promise.resolve()) + return session +} + +function mockWindows() { + const windows = windowManager() + jest + .spyOn(windows, "confirmQuit") + .mockImplementation(() => Promise.resolve(true)) + jest + .spyOn(windows, "prepareQuit") + .mockImplementation(() => Promise.resolve([])) + jest.spyOn(windows, "quit").mockImplementation(() => Promise.resolve()) + return windows +} + +test("reset state", async () => { + const brim = new Brim({ + zqd: mockZqd(), + session: mockSession(), + windows: mockWindows() + }) + + await brim.start() + await brim.resetState() + + expect(brim.session.delete).toHaveBeenCalled() + expect(brim.session.save).not.toHaveBeenCalled() + expect(app.relaunch).toHaveBeenCalled() + expect(brim.isQuitting).toBe(true) +}) diff --git a/src/js/electron/brim.test.ts b/src/js/electron/brim-start.test.ts similarity index 51% rename from src/js/electron/brim.test.ts rename to src/js/electron/brim-start.test.ts index 2a2b8af6f6..eb6dabbedc 100644 --- a/src/js/electron/brim.test.ts +++ b/src/js/electron/brim-start.test.ts @@ -1,42 +1,57 @@ +import {ZQD} from "ppl/zqd/zqd" +import {Brim} from "./brim" +import {installExtensions} from "./extensions" + jest.mock("./extensions", () => ({ installExtensions: jest.fn() })) -import {installExtensions} from "./extensions" -import {Brim} from "./brim" + +function mockZqd() { + const zqd = new ZQD("test", "srun", "supdate", "zrun") + jest.spyOn(zqd, "start").mockImplementation(() => {}) + return zqd +} + +let brim: Brim +beforeEach(() => { + brim = new Brim({ + zqd: mockZqd() + }) +}) + +test("start is called in zqd", async () => { + await brim.start() + expect(brim.zqd.start).toHaveBeenCalledTimes(1) +}) test("activate when zero windows open", () => { - const brim = new Brim() expect(brim.windows.count()).toBe(0) brim.activate() expect(brim.windows.count()).toBe(1) }) -test("actiate when one or more windows open", () => { - const brim = new Brim() +test("actiate when one or more windows open", async () => { brim.activate() expect(brim.windows.count()).toBe(1) brim.activate() expect(brim.windows.count()).toBe(1) }) -test("start opens a window", () => { - const brim = new Brim() - brim.start() +test("start opens a window", async () => { + await brim.start() expect(brim.windows.count()).toBe(1) }) -test("start installs dev extensions if is dev", () => { - const brim = new Brim() +test("start installs dev extensions if is dev", async () => { jest.spyOn(brim, "isDev").mockImplementation(() => true) - brim.start() + await brim.start() expect(installExtensions).toHaveBeenCalled() // @ts-ignore installExtensions.mockReset() }) -test("start does not install dev extensions if not dev", () => { - const brim = new Brim() +test("start does not install dev extensions if not dev", async () => { jest.spyOn(brim, "isDev").mockImplementation(() => false) - brim.start() + await brim.start() expect(installExtensions).not.toHaveBeenCalled() }) diff --git a/src/js/electron/brim.ts b/src/js/electron/brim.ts index 88e960a992..319dc9f21c 100644 --- a/src/js/electron/brim.ts +++ b/src/js/electron/brim.ts @@ -1,26 +1,99 @@ import windowManager, {$WindowManager} from "./tron/windowManager" import isDev from "./isDev" import {installExtensions} from "./extensions" -import {SessionState} from "./tron/formatSessionState" +import formatSessionState from "./tron/formatSessionState" +import createGlobalStore from "../state/createGlobalStore" +import {Store} from "redux" +import tron, {Session} from "./tron" +import Prefs from "../state/Prefs" +import {ZQD} from "ppl/zqd/zqd" +import {app} from "electron" +import path from "path" +import {sessionStateFile} from "./tron/session" + +type QuitOpts = { + saveSession?: boolean +} + +type BrimArgs = { + windows?: $WindowManager + store?: Store + session?: Session + zqd?: ZQD +} export class Brim { readonly windows: $WindowManager - readonly data: SessionState | undefined + readonly store: Store + readonly zqd: ZQD + readonly session: Session + public isQuitting = false - constructor(data?: SessionState) { - this.data = data - this.windows = windowManager() + static async boot( + sessionPath: string = sessionStateFile(), + createSession = tron.session + ) { + const session = createSession(sessionPath) + const data = await session.load() + const windows = windowManager(data) + const store = createGlobalStore(data?.globalState) + const select = (fn) => fn(store.getState()) + const suricataRunner = select(Prefs.getSuricataRunner) + const suricataUpdater = select(Prefs.getSuricataUpdater) + const zeekRunner = select(Prefs.getZeekRunner) + const spaceDir = path.join(app.getPath("userData"), "data", "spaces") + const zqd = new ZQD(spaceDir, suricataRunner, suricataUpdater, zeekRunner) + return new Brim({session, windows, store, zqd}) + } + + constructor(args: BrimArgs = {}) { + this.windows = args.windows || windowManager() + this.store = args.store || createGlobalStore(undefined) + this.session = args.session || tron.session() + this.zqd = args.zqd || new ZQD(null, null, null, null) } async start() { + this.zqd.start() if (this.isDev()) await installExtensions() - this.windows.init(this.data) + this.windows.init() } activate() { if (this.windows.count() === 0) this.windows.init() } + async resetState() { + await this.session.delete() + app.relaunch() + this.quit({saveSession: false}) + } + + async saveSession() { + const windowState = await this.windows.serialize() + const mainState = this.store.getState() + await this.session.save(formatSessionState(windowState, mainState)) + } + + async deleteSession() { + await this.session.delete() + } + + async quit(opts: QuitOpts = {saveSession: true}) { + this.isQuitting = true + if (await this.windows.confirmQuit()) { + await this.windows.prepareQuit() + if (opts.saveSession) { + await this.saveSession() + } + this.windows.quit() + await this.zqd.close() + app.quit() + } else { + this.isQuitting = false + } + } + isDev() { return isDev } diff --git a/src/js/electron/config.ts b/src/js/electron/config.ts deleted file mode 100644 index 0e31ce1d8e..0000000000 --- a/src/js/electron/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {app} from "electron" -import path from "path" - -export default { - windowStateFile: () => path.join(app.getPath("userData"), "windowState.json") -} diff --git a/src/js/electron/ipc/globalStore/mainHandler.ts b/src/js/electron/ipc/globalStore/mainHandler.ts index dd0b18ccfe..d37859962b 100644 --- a/src/js/electron/ipc/globalStore/mainHandler.ts +++ b/src/js/electron/ipc/globalStore/mainHandler.ts @@ -1,19 +1,18 @@ import {ipcMain} from "electron" - -import {$WindowManager} from "../../tron/windowManager" import ipc from ".." import sendTo from "../sendTo" +import {Brim} from "../../brim" -export default function(store: any, winMan: $WindowManager) { +export default function(brim: Brim) { ipcMain.handle("globalStore:init", () => { return { - initialState: store.getState() + initialState: brim.store.getState() } }) ipcMain.handle("globalStore:dispatch", (e, {action}) => { - store.dispatch(action) - for (const win of winMan.getWindows()) { + brim.store.dispatch(action) + for (const win of brim.windows.getWindows()) { if (!win.ref.isDestroyed()) { sendTo(win.ref.webContents, ipc.globalStore.dispatch(action)) } diff --git a/src/js/electron/ipc/windows/mainHandler.ts b/src/js/electron/ipc/windows/mainHandler.ts index 21f77f784e..76ffff3913 100644 --- a/src/js/electron/ipc/windows/mainHandler.ts +++ b/src/js/electron/ipc/windows/mainHandler.ts @@ -1,24 +1,22 @@ import log from "electron-log" - import {BrowserWindow, dialog, ipcMain} from "electron" - -import {$WindowManager} from "../../tron/windowManager" +import {Brim} from "../../brim" let started = false -export default function(manager: $WindowManager) { +export default function(brim: Brim) { ipcMain.handle("windows:initialState", (_e, {id}) => { - const window = manager.getWindow(id) + const window = brim.windows.getWindow(id) return window.initialState }) ipcMain.handle("windows:open", (e, args) => { - manager.openWindow(args.name, args.params, args.state) + brim.windows.openWindow(args.name, args.params, args.state) }) ipcMain.handle("windows:close", () => { - manager.closeWindow() + brim.windows.closeWindow() }) ipcMain.handle("windows:ready", () => { @@ -29,7 +27,7 @@ export default function(manager: $WindowManager) { }) ipcMain.handle("windows:newSearchTab", (e, params) => { - manager.openSearchTab(params.params) + brim.windows.openSearchTab(params.params) }) ipcMain.handle("windows:log", (e, {id, args}) => { diff --git a/src/js/electron/main.ts b/src/js/electron/main.ts index c4ff923477..eb15f9434a 100644 --- a/src/js/electron/main.ts +++ b/src/js/electron/main.ts @@ -1,15 +1,12 @@ import {appPathSetup} from "./appPathSetup" -import Prefs from "../state/Prefs" import userTasks from "./userTasks" // app path and log setup should happen before other imports. appPathSetup() -import createGlobalStore from "../state/createGlobalStore" import globalStoreMainHandler from "./ipc/globalStore/mainHandler" import menu from "./menu" import windowsMainHandler from "./ipc/windows/mainHandler" -import zqdMainHandler from "./ipc/zqd/mainHandler" console.time("init") import "regenerator-runtime/runtime" @@ -17,9 +14,6 @@ import "regenerator-runtime/runtime" import {app} from "electron" import {handleSquirrelEvent} from "./squirrel" -import tron from "./tron" -import path from "path" -import {ZQD} from "ppl/zqd/zqd" import electronIsDev from "./isDev" import {setupAutoUpdater} from "./autoUpdater" import log from "electron-log" @@ -29,22 +23,11 @@ import {Brim} from "./brim" async function main() { if (handleSquirrelEvent(app)) return userTasks(app) - const session = tron.session() - const data = await session.load() - const brim = new Brim(data) - const winMan = brim.windows - const store = createGlobalStore(data ? data.globalState : undefined) - const spaceDir = path.join(app.getPath("userData"), "data", "spaces") - const suricataRunner = Prefs.getSuricataRunner(store.getState()) - const suricataUpdater = Prefs.getSuricataUpdater(store.getState()) - const zeekRunner = Prefs.getZeekRunner(store.getState()) - const zqd = new ZQD(spaceDir, suricataRunner, suricataUpdater, zeekRunner) - - menu.setMenu(winMan, store, session) - zqdMainHandler(zqd) - windowsMainHandler(winMan) - globalStoreMainHandler(store, winMan) - handleQuit(winMan, store, session, zqd) + const brim = await Brim.boot() + menu.setMenu(brim) + windowsMainHandler(brim) + globalStoreMainHandler(brim) + handleQuit(brim) // autoUpdater should not run in dev, and will fail if the code has not been signed if (!electronIsDev) { @@ -57,10 +40,10 @@ async function main() { for (let arg of argv) { switch (arg) { case "--new-window": - winMan.openWindow("search") + brim.windows.openWindow("search") break case "--move-to-current-display": - winMan.moveToCurrentDisplay() + brim.windows.moveToCurrentDisplay() break } } diff --git a/src/js/electron/menu/appMenu.test.ts b/src/js/electron/menu/appMenu.test.ts index fe3b597be9..929a850da5 100644 --- a/src/js/electron/menu/appMenu.test.ts +++ b/src/js/electron/menu/appMenu.test.ts @@ -1,34 +1,16 @@ import appMenu from "./appMenu" -import initTestStore from "../../test/initTestStore" -import tron from "../tron" -import {$WindowManager} from "../tron/windowManager" +import {Brim} from "../brim" const mockSend = jest.fn() -const mockWindownManager = {} as $WindowManager -const mockStore = initTestStore() test("app menu mac", async () => { - const mockSession = await tron.session() - const menu = appMenu( - mockSend, - mockWindownManager, - mockStore, - mockSession, - "darwin" - ) + const menu = appMenu(mockSend, new Brim(), "darwin") expect(menu).toMatchSnapshot() }) test("app menu windows", async () => { - const mockSession = await tron.session() + const menu = appMenu(mockSend, new Brim(), "win32") - const menu = appMenu( - mockSend, - mockWindownManager, - mockStore, - mockSession, - "win32" - ) expect(menu).toMatchSnapshot() }) diff --git a/src/js/electron/menu/appMenu.ts b/src/js/electron/menu/appMenu.ts index f96cd69474..533549549e 100644 --- a/src/js/electron/menu/appMenu.ts +++ b/src/js/electron/menu/appMenu.ts @@ -3,18 +3,13 @@ import {app, dialog, shell, MenuItemConstructorOptions} from "electron" import path from "path" -import {$WindowManager} from "../tron/windowManager" -import {Session} from "../tron" -import config from "../config" import electronIsDev from "../isDev" import formatSessionState from "../tron/formatSessionState" -import lib from "../../lib" +import {Brim} from "../brim" -export default function appMenu( +export default function( send: Function, - manager: $WindowManager, - store: any, - session: Session, + brim: Brim, platform: string = process.platform ): MenuItemConstructorOptions[] { const mac = platform === "darwin" @@ -23,7 +18,7 @@ export default function appMenu( const newWindow: MenuItemConstructorOptions = { label: "New Window", accelerator: "CmdOrCtrl+N", - click: () => manager.openWindow("search", {}) + click: () => brim.windows.openWindow("search", {}) } const exit: MenuItemConstructorOptions = { @@ -34,13 +29,13 @@ export default function appMenu( const aboutBrim: MenuItemConstructorOptions = { label: "About Brim", click() { - manager.openAbout() + brim.windows.openAbout() } } const closeWindow: MenuItemConstructorOptions = { label: "Close Window", - click: () => manager.closeWindow() + click: () => brim.windows.closeWindow() } const closeTab: MenuItemConstructorOptions = { @@ -51,17 +46,18 @@ export default function appMenu( const preferences: MenuItemConstructorOptions = { id: "preferences", label: platform === "darwin" ? "Preferences..." : "Settings", - click: () => manager.openPreferences() + click: () => brim.windows.openPreferences() } const resetState: MenuItemConstructorOptions = { label: "Reset State", - click: () => { - send("resetState") - lib - .file(config.windowStateFile()) - .remove() - .catch(() => {}) + click: async () => { + const {response} = await dialog.showMessageBox({ + message: "Are you sure?", + detail: "This will reset local app state, but retain workspace data.", + buttons: ["OK", "Cancel"] + }) + if (response === 0) await brim.resetState() } } @@ -239,13 +235,13 @@ export default function appMenu( label: "Save Session for Testing Migrations", async click() { const root = app.getAppPath() - const version = session.getVersion() + const version = brim.session.getVersion() const file = path.join(root, `src/js/test/states/${version}.json`) const data = formatSessionState( - await manager.serialize(), - store.getState() + await brim.windows.serialize(), + brim.store.getState() ) - await session.save(data, file) + await brim.session.save(data, file) dialog.showMessageBox({ message: `Session has been saved`, detail: file diff --git a/src/js/electron/menu/index.ts b/src/js/electron/menu/index.ts index e2c07f04b7..c724afa08c 100644 --- a/src/js/electron/menu/index.ts +++ b/src/js/electron/menu/index.ts @@ -1,21 +1,19 @@ import {BrowserWindow, Menu, MenuItemConstructorOptions} from "electron" +import {Brim} from "../brim" -import {$WindowManager} from "../tron/windowManager" import actions from "./actions" import appMenu from "./appMenu" export type $MenuItem = MenuItemConstructorOptions export type $Menu = $MenuItem[] -function setMenu(manager: $WindowManager, store: any, session: any) { +function setMenu(brim: Brim) { function send(channel, ...args) { let win = BrowserWindow.getFocusedWindow() if (win && win.webContents) win.webContents.send(channel, ...args) } - Menu.setApplicationMenu( - Menu.buildFromTemplate(appMenu(send, manager, store, session)) - ) + Menu.setApplicationMenu(Menu.buildFromTemplate(appMenu(send, brim))) } export default { diff --git a/src/js/electron/quitter.ts b/src/js/electron/quitter.ts index 7dc60bed9e..4685111b56 100644 --- a/src/js/electron/quitter.ts +++ b/src/js/electron/quitter.ts @@ -1,25 +1,11 @@ import {app} from "electron" -import formatSessionState from "./tron/formatSessionState" -import {$WindowManager} from "./tron/windowManager" - -export function handleQuit(manager: $WindowManager, store, session, zqd) { - let quitting = false +import {Brim} from "./brim" +export function handleQuit(brim: Brim) { app.on("before-quit", async (e) => { - if (quitting) return + if (brim.isQuitting) return e.preventDefault() - quitting = true - if (await manager.confirmQuit()) { - await manager.prepareQuit() - session.save( - formatSessionState(await manager.serialize(), store.getState()) - ) - manager.quit() - await zqd.close() - app.quit() - } else { - quitting = false - } + await brim.quit() }) app.on("window-all-closed", async () => { @@ -27,7 +13,7 @@ export function handleQuit(manager: $WindowManager, store, session, zqd) { // Strangely, this event fires before the "closed" event on the window is fired, // where we dereference the window. Here we check to make sure all the windows // have indeed been dereferenced before we quit the app. - await manager.whenAllClosed() + await brim.windows.whenAllClosed() app.quit() }) } diff --git a/src/js/electron/tron/session.ts b/src/js/electron/tron/session.ts index 8f77318cd2..b96309e4a5 100644 --- a/src/js/electron/tron/session.ts +++ b/src/js/electron/tron/session.ts @@ -41,11 +41,18 @@ export default function session(path: string = sessionStateFile()) { } else { return undefined } + }, + + async delete() { + const file = lib.file(path) + if (await file.exists()) { + return file.remove() + } } } } -function sessionStateFile() { +export function sessionStateFile() { // This can't be a const because we adjust the // userData path first thing inside main(). return path.join(app.getPath("userData"), "appState.json") diff --git a/src/js/electron/tron/windowManager.ts b/src/js/electron/tron/windowManager.ts index 065df1c76e..963555d4ee 100644 --- a/src/js/electron/tron/windowManager.ts +++ b/src/js/electron/tron/windowManager.ts @@ -39,11 +39,13 @@ export interface BrimWindow { close: () => void } -export default function windowManager() { +export default function windowManager( + session?: SessionState | null | undefined +) { let windows: WindowsState = {} return { - init(session?: SessionState | null | undefined) { + init() { if (!session || (session && session.order.length === 0)) { this.openWindow("search") } else { diff --git a/src/js/initializers/initIpcListeners.ts b/src/js/initializers/initIpcListeners.ts index 6a4cfbe77b..ac05c8ed31 100644 --- a/src/js/initializers/initIpcListeners.ts +++ b/src/js/initializers/initIpcListeners.ts @@ -52,10 +52,6 @@ export default (store: Store) => { store.dispatch(Layout.toggleRightSidebar()) }) - ipcRenderer.on("resetState", () => { - /* Will implement soon */ - }) - ipcRenderer.on("getState", (event, channel) => { ipcRenderer.send(channel, getPersistable(store.getState())) }) diff --git a/src/js/test/setup.ts b/src/js/test/setup.ts index 6b4aa6b0ae..ca55168037 100644 --- a/src/js/test/setup.ts +++ b/src/js/test/setup.ts @@ -24,6 +24,7 @@ jest.mock("electron", function() { getPosition() { return [0, 0] } + destroy() {} } const electron = { @@ -32,7 +33,9 @@ jest.mock("electron", function() { getName: () => "TestApp", getPath: () => "/fake/path", getVersion: () => "test-version", - getAppPath: () => "fake/app/path" + getAppPath: () => "fake/app/path", + quit: jest.fn(), + relaunch: jest.fn() }, getCurrentWebContents: jest.fn(() => ({ send: jest.fn()