diff --git a/app/actions/ui.ts b/app/actions/ui.ts index f7f89896..8b98509c 100644 --- a/app/actions/ui.ts +++ b/app/actions/ui.ts @@ -9,10 +9,11 @@ import { UI_SET_DATASET_DIR_PATH, UI_SET_EXPORT_PATH, UI_SET_DETAILS_BAR, - UI_SET_IMPORT_FILE_DETAILS + UI_SET_IMPORT_FILE_DETAILS, + UI_SET_BOOTUP_COMPONENT } from '../reducers/ui' -import { ToastType } from '../models/store' +import { ToastType, BootupComponentType } from '../models/store' import { Modal, ModalType } from '../models/modals' import { Details } from '../models/details' @@ -100,3 +101,10 @@ export const setImportFileDetails = (fileName: string, fileSize: number) => { fileSize } } + +export const setBootupComponent = (component: BootupComponentType) => { + return { + type: UI_SET_BOOTUP_COMPONENT, + component + } +} diff --git a/app/backend.js b/app/backend.js index e45420ef..6474924d 100644 --- a/app/backend.js +++ b/app/backend.js @@ -1,10 +1,13 @@ const log = require('electron-log') const childProcess = require('child_process') const fs = require('fs') +const yaml = require('js-yaml') const path = require('path') const os = require('os') +const http = require('http') const { dialog } = require('electron') +const lowestCompatibleBackend = [0, 9, 9] // BackendProcess runs the qri backend binary in connected'ed mode, to handle api requests. class BackendProcess { @@ -12,10 +15,24 @@ class BackendProcess { this.qriBinPath = null this.process = null this.debugLogPath = null + this.backendVer = null // Default to writing to stdout & stderr this.out = process.stdout - this.err = process.stderr + this.in = process.in + this.err = process.stderr; + + [ + 'setQriBinPath', + 'setBackendVer', + 'standardRepoPath', + 'checkNoActiveBackendProcess', + 'checkBackendCompatibility', + 'checkNeedsMigration', + 'launchProcess' + ].forEach((m) => { this[m] = this[m].bind(this) }) + + try { // Create a log whose filename contains the current day. const nowTime = new Date(); @@ -41,9 +58,12 @@ class BackendProcess { detail: err }) } + this.setQriBinPath() + this.setBackendVer() } - maybeStartup () { + // running this function will ensure that a qriBinPath exists + setQriBinPath () { // In development node, use installed qri binary if (process.env.NODE_ENV === 'development') { let processResult = childProcess.execSync('which qri'); @@ -59,12 +79,17 @@ class BackendProcess { } if (!this.qriBinPath) { - log.warn('no qri bin path found, backend launch') - return + log.warn('no qri bin path found, backend launch failed') + throw new Error("no qri bin path found, backend launch failed") } - // Run the binary if it is found log.info(`found qri binary at path: ${this.qriBinPath}`) - this.launchProcess() + } + + setBackendVer () { + let processResult = childProcess.execSync(`"${this.qriBinPath}" version`) + this.backendVer = processResult.toString().trim() + log.info("qri backend version", this.backendVer) + } close () { @@ -74,16 +99,66 @@ class BackendProcess { } } + standardRepoPath() { + var qriRepoPath = process.env.QRI_PATH + if (qriRepoPath === "") { + home = os.homedir() + qriRepoPath = path.join(home, ".qri") + } + + log.info(`QRI_PATH is ${qriRepoPath}`) + return qriRepoPath + } + + async checkNoActiveBackendProcess() { + const healthCheck = async () => { + return new Promise((res, rej) => { + http.get('http://localhost:2503/health', (data) => { + res(true) + }).on('error', (e) => { + res(false) + }) + }) + } + + var isQriRunning = await healthCheck() + if (isQriRunning) { + throw new Error("backend-already-running") + } + return + } + + async checkBackendCompatibility () { + log.info(`checking to see if given backend version ${this.backendVer} is compatible with expected version ${lowestCompatibleBackend.join(".")}`) + let compatible = false + try { + let ver = this.backendVer + if (this.backendVer.indexOf("-dev") !== -1) { + ver = ver.slice(0, this.backendVer.indexOf("-dev")) + } + ver = ver.split(".").map((i) => parseInt(i)) + compatible = lowestCompatibleBackend.every((val, i) => { + if (val <= ver[i]) { + return true + } + return false + }) + } catch (e) { + throw e + } + if (!compatible) { + throw new Error("incompatible-backend") + } + } + launchProcess () { try { - let processResult = childProcess.execSync(`"${this.qriBinPath}" version`) - let qriBinVersion = processResult.toString().trim(); - this.process = childProcess.spawn(this.qriBinPath, ['connect', '--setup', '--log-all'], { stdio: ['ignore', this.out, this.err] }) + this.process = childProcess.spawn(this.qriBinPath, ['connect', '--migrate', '--log-all', '--setup', '--no-prompt'], { stdio: ['ignore', this.out, this.err] }) this.process.on('error', (err) => { this.handleEvent('error', err) }) this.process.on('exit', (err) => { this.handleEvent('exit', err) }) this.process.on('close', (err) => { this.handleEvent('close', err) }) this.process.on('disconnect', (err) => { this.handleEvent('disconnect', err) }) - log.info(`starting up qri backend version ${qriBinVersion}`) + log.info(`starting up qri backend version ${this.backendVer}`) } catch (err) { log.error(`starting background process: ${err}`) } @@ -92,11 +167,26 @@ class BackendProcess { handleEvent (kind, err) { if (err) { log.error(`event ${kind} from backend: ${err}`) + throw err } else { log.warn(`event ${kind} from backend`) } } + checkNeedsMigration() { + log.info("checking for backend migrations") + try { + childProcess.execSync(`"${this.qriBinPath}" config get`) + } catch (err) { + // status code 2 means we need to run a migration + if (err.status == 2) { + return true + } + log.error(`error checking for migration: ${err}`) + } + return false + } + findQriBin (pathList) { for (let i = 0; i < pathList.length; i++) { let binPath = path.join(pathList[i], '/backend/qri') diff --git a/app/components/App.tsx b/app/components/App.tsx index ce1f36cd..35702a94 100644 --- a/app/components/App.tsx +++ b/app/components/App.tsx @@ -13,19 +13,19 @@ import { DEFAULT_POLL_INTERVAL } from '../constants' import { history } from '../store/configureStore.development' import { ApiAction } from '../store/api' import { Modal, ModalType } from '../models/modals' -import { Selections, ApiConnection } from '../models/store' +import { Selections, ApiConnection, BootupComponentType } from '../models/store' import { Session } from '../models/session' // import util funcs import { connectComponentToProps } from '../utils/connectComponentToProps' // import actions -import { setModal } from '../actions/ui' +import { setModal, setBootupComponent } from '../actions/ui' import { pingApi } from '../actions/api' import { bootstrap } from '../actions/session' // import selections -import { selectSelections, selectModal, selectApiConnection, selectSession } from '../selections' +import { selectSelections, selectModal, selectApiConnection, selectSession, selectBootupComponent } from '../selections' // import components import Toast from './Toast' @@ -34,10 +34,14 @@ import AppError from './AppError' import AppLoading from './AppLoading' import Modals from './modals/Modals' import Routes from '../routes' +import MigratingBackend from './MigratingBackend' +import MigrationFailed from './MigrationFailed' +import IncompatibleBackend from './IncompatibleBackend' // declare interface for props export interface AppProps { loading: boolean + bootupComponent: BootupComponentType session: Session selections: Selections apiConnection?: ApiConnection @@ -46,6 +50,7 @@ export interface AppProps { push: (path: string) => void setModal: (modal: Modal) => Action + setBootupComponent: (component: BootupComponentType) => Action bootstrap: () => Promise pingApi: () => Promise @@ -73,6 +78,14 @@ class AppComponent extends React.Component { this.handleReload = this.handleReload.bind(this) this.handleSetDebugLogPath = this.handleSetDebugLogPath.bind(this) this.handleExportDebugLog = this.handleExportDebugLog.bind(this) + this.handleIncompatibleBackend = this.handleIncompatibleBackend.bind(this) + this.handleMigratingBackend = this.handleMigratingBackend.bind(this) + this.handleMigrationFailure = this.handleMigrationFailure.bind(this) + this.handleStartingBackend = this.handleStartingBackend.bind(this) + } + + private handleStartingBackend () { + console.log("started backend message received") } private handleCreateDataset () { @@ -112,6 +125,18 @@ class AppComponent extends React.Component { fs.copyFileSync(this.state.debugLogPath, exportFilename) } + private handleIncompatibleBackend (_: Electron.IpcRendererEvent, ver: string) { + this.props.setBootupComponent(ver) + } + + private handleMigratingBackend () { + this.props.setBootupComponent('migrating') + } + + private handleMigrationFailure () { + this.props.setBootupComponent('migrationFailure') + } + componentDidMount () { // handle ipc events from electron menus ipcRenderer.on('create-dataset', this.handleCreateDataset) @@ -120,6 +145,12 @@ class AppComponent extends React.Component { ipcRenderer.on('set-debug-log-path', this.handleSetDebugLogPath) ipcRenderer.on('export-debug-log', this.handleExportDebugLog) ipcRenderer.on('reload', this.handleReload) + ipcRenderer.on('incompatible-backend', this.handleIncompatibleBackend) + ipcRenderer.on('migrating-backend', this.handleMigratingBackend) + ipcRenderer.on('migration-failed', this.handleMigrationFailure) + ipcRenderer.on("starting-backend", this.handleStartingBackend) + + ipcRenderer.send("app-fully-loaded") setInterval(() => { if (this.props.apiConnection !== 1) { @@ -138,6 +169,10 @@ class AppComponent extends React.Component { ipcRenderer.removeListener('history-push', this.handlePush) ipcRenderer.removeListener('set-debug-log-path', this.handleSetDebugLogPath) ipcRenderer.removeListener('reload', this.handleReload) + ipcRenderer.removeListener('incompatible-backend', this.handleIncompatibleBackend) + ipcRenderer.removeListener('migrating-backend', this.handleMigratingBackend) + ipcRenderer.removeListener('migration-failed', this.handleMigrationFailure) + ipcRenderer.removeListener("starting-backend", this.handleStartingBackend) } componentDidUpdate (prevProps: AppProps) { @@ -151,20 +186,52 @@ class AppComponent extends React.Component { } render () { - const { apiConnection, modal, loading } = this.props + const { apiConnection, modal, loading, bootupComponent } = this.props if (loading) { return ( - - - + <> + + + + + + + + + + + + + ) } @@ -228,6 +295,7 @@ export default connectComponentToProps( selections: selectSelections(state), apiConnection, modal: selectModal(state), + bootupComponent: selectBootupComponent(state), ...ownProps } }, @@ -236,6 +304,7 @@ export default connectComponentToProps( push, setModal, bootstrap, + setBootupComponent, pingApi } ) diff --git a/app/main.development.js b/app/main.development.js index 81e663cb..23b0ce96 100644 --- a/app/main.development.js +++ b/app/main.development.js @@ -102,8 +102,6 @@ app.on('ready', () => log.info('main process ready') autoUpdater.logger = log autoUpdater.checkForUpdatesAndNotify() - backendProcess = new BackendProcess() - backendProcess.maybeStartup() mainWindow = new BrowserWindow({ show: false, @@ -553,5 +551,49 @@ app.on('ready', () => quitting = true }) + ipcMain.on('app-fully-loaded', () => { + log.info("starting backend process") + backendProcess = new BackendProcess() + backendProcess.checkNoActiveBackendProcess() + .then(backendProcess.checkBackendCompatibility) + .then(backendProcess.checkNeedsMigration) + .then((needsMigration) => { + if (needsMigration) { + log.info("migrating backend") + // this doesn't trigger migrations, which happens automatically + // when we run `launchProcess`, but instead alerts the user + // that we are running a migration and it might take a moment + mainWindow.webContents.send("migrating-backend") + } + }) + .then(backendProcess.launchProcess) + .catch(err => { + switch (err.message) { + case "backend-already-running": + log.info("a qri backend is already running at port 2503") + mainWindow.webContents.send("backend-already-running") + break + case "incompatible-backend": + log.info("qri backend is incompatible") + mainWindow.webContents.send("incompatible-backend", backendProcess.backendVer) + break + case "migration-failed": + log.debug("migration-failed") + mainWindow.webContents.send("migration-failed") + break + case "error-launching-backend": + log.debug("error-launching-backend") + mainWindow.webContents.send("error-launching-backend") + break + default: + log.error(err.message) + } + // if we error here, we should close the process + log.info("closing backend process") + backendProcess.close() + }) + }) + log.info('app launched') - })) + }) + ) diff --git a/app/models/store.ts b/app/models/store.ts index eadcb988..98cb5731 100644 --- a/app/models/store.ts +++ b/app/models/store.ts @@ -75,8 +75,11 @@ export interface UI { detailsBar: Details importFileName: string importFileSize: number + bootupComponent: BootupComponentType } +export type BootupComponentType = 'loading' | 'migrating' | 'migrationFailure' | string + export type SelectedComponent = 'commit' | 'readme' | 'meta' | 'body' | 'structure' | 'transform' | '' // currently selected dataset, tab, dataset component, commit, etc diff --git a/app/reducers/ui.ts b/app/reducers/ui.ts index 83f49cbc..bb935216 100644 --- a/app/reducers/ui.ts +++ b/app/reducers/ui.ts @@ -24,6 +24,7 @@ export const UI_SET_DATASET_DIR_PATH = 'UI_SET_DATASET_DIR_PATH' export const UI_SET_EXPORT_PATH = 'UI_SET_EXPORT_PATH' export const UI_SET_DETAILS_BAR = 'UI_SET_DETAILS_BAR' export const UI_SET_IMPORT_FILE_DETAILS = 'UI_SET_IMPORT_FILE_DETAILS' +export const UI_SET_BOOTUP_COMPONENT = 'UI_SET_BOOTUP_COMPONENT' export const UNAUTHORIZED = 'UNAUTHORIZED' @@ -62,7 +63,8 @@ const initialState = { modal: { type: ModalType.NoModal }, detailsBar: { type: DetailsType.NoDetails }, importFileName: '', - importFileSize: 0 + importFileSize: 0, + bootupComponent: 'loading' } // send an event to electron to block menus on first load @@ -206,6 +208,12 @@ export default (state = initialState, action: AnyAction) => { importFileName: fileName } + case UI_SET_BOOTUP_COMPONENT: + const { component } = action + return { + ...state, + bootupComponent: component + } default: return state } diff --git a/app/selections.ts b/app/selections.ts index f8e73bec..40d20db8 100644 --- a/app/selections.ts +++ b/app/selections.ts @@ -10,7 +10,8 @@ import Store, { Selections, Toast, ApiConnection, - StatusInfo + StatusInfo, + BootupComponentType } from './models/store' import { Details, DetailsType } from "./models/details" import { datasetToVersionInfo } from "./actions/mappingFuncs" @@ -309,6 +310,9 @@ export function selectToast (state: Store): Toast { return state.ui.toast } +export function selectBootupComponent (state: Store): BootupComponentType { + return state.ui.bootupComponent +} /** * * WORKINGDATASET STATE TREE