diff --git a/client/client.ts b/client/client.ts index 7986fbe7..788f45ec 100644 --- a/client/client.ts +++ b/client/client.ts @@ -1,29 +1,18 @@ import omit from 'lodash/omit' -import * as settings from './settings' import * as types from './types' export class Client { serverUrl?: string - constructor(props: { serverUrl?: string } = {}) { - this.serverUrl = props.serverUrl - } - - async readServerUrl() { - return ( - this.serverUrl || - // @ts-ignore - (await window?.opendataeditor?.readServerUrl()) || - settings.SERVER_URL - ) + constructor() { + this.serverUrl = 'http://localhost:4040' } async request( path: string, props: { [key: string]: any; file?: File; isBytes?: boolean } = {} ) { - const serverUrl = await this.readServerUrl() - return makeRequest(serverUrl + path, props) + return makeRequest(this.serverUrl + path, props) } // Article diff --git a/client/components/Application/Dialog.tsx b/client/components/Application/Dialog.tsx index 7e330961..ca1650ac 100644 --- a/client/components/Application/Dialog.tsx +++ b/client/components/Application/Dialog.tsx @@ -10,12 +10,12 @@ import DeleteFolderDialog from './Dialogs/DeleteFolder' import IndexFilesDialog from './Dialogs/IndexFiles' import MoveFileDialog from './Dialogs/MoveFile' import MoveFolderDialog from './Dialogs/MoveFolder' -import StartDialog from './Dialogs/Start' import { useStore } from './store' export default function Dialog() { const dialog = useStore((state) => state.dialog) if (!dialog) return null + // @ts-ignore const Dialog = DIALOGS[dialog] return } @@ -34,5 +34,4 @@ const DIALOGS = { indexFiles: IndexFilesDialog, moveFile: MoveFileDialog, moveFolder: MoveFolderDialog, - start: StartDialog, } diff --git a/client/components/Application/Dialogs/Start.tsx b/client/components/Application/Dialogs/Start.tsx deleted file mode 100644 index 7cfd3371..00000000 --- a/client/components/Application/Dialogs/Start.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import Box from '@mui/material/Box' -import WaitDialog from '../../Parts/Dialogs/Wait' -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch' - -export default function StartDialog() { - return ( - - - - Initializing the application! - {' '} - - If it is a first run on your computer, it may take - a few minutes - {' '} - to download dependencies. Next time it will be instant! - - - ) -} diff --git a/client/components/Application/store.ts b/client/components/Application/store.ts index d39a617f..29c88271 100644 --- a/client/components/Application/store.ts +++ b/client/components/Application/store.ts @@ -84,9 +84,7 @@ export function makeStore(props: ApplicationProps) { onStart: async () => { const { client, loadConfig, loadFiles, updateState } = get() - updateState({ dialog: 'start' }) - // Wait for the server // @ts-ignore const sendFatalError = window?.opendataeditor?.sendFatalError let ready = false @@ -101,21 +99,18 @@ export function makeStore(props: ApplicationProps) { } catch (error) { attempt += 1 if (attempt >= maxAttempts) { - const serverUrl = await client.readServerUrl() + const serverUrl = client.serverUrl const message = `Client cannot connect to server on "${serverUrl}"` sendFatalError ? sendFatalError(message) : alert(message) } await delay(delaySeconds * 1000) } } - // Setup project sync polling setInterval(async () => { const { files } = await client.projectSync({}) updateState({ files }) }, settings.PROJECT_SYNC_INTERVAL_MILLIS) - - updateState({ dialog: undefined }) }, onFileCreate: async (paths) => { const { loadFiles, selectFile } = get() diff --git a/client/components/Parts/Dialogs/Wait.tsx b/client/components/Parts/Dialogs/Wait.tsx deleted file mode 100644 index f56d1b0a..00000000 --- a/client/components/Parts/Dialogs/Wait.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react' -import Box from '@mui/material/Box' -import Dialog from '@mui/material/Dialog' -import DialogTitle from '@mui/material/DialogTitle' -import DialogContent from '@mui/material/DialogContent' -import LinearProgress from '@mui/material/LinearProgress' - -export interface WaitDialogProps { - open?: boolean - title?: string - maxWidth?: 'md' | 'xl' - children?: React.ReactNode -} - -export default function WaitDialog(props: WaitDialogProps) { - return ( - - - {props.title || 'Dialog'} - - - - - - {props.children} - - - ) -} diff --git a/client/loading.html b/client/loading.html new file mode 100644 index 00000000..c0fa139f --- /dev/null +++ b/client/loading.html @@ -0,0 +1,71 @@ + + + + + Open Data Editor + + + + + +
+
+

Open Data Editor is loading

+

The first time launching Open Data Editor needs to ensure requirements are installed.

+
    +
  • Ensure python is installed
  • +
  • Ensure python dependencies are installed
  • +
  • Ensure server is running
  • +
+
+
+ + diff --git a/desktop/bridge.ts b/desktop/bridge.ts index 527f0973..6bb38c2d 100644 --- a/desktop/bridge.ts +++ b/desktop/bridge.ts @@ -1,10 +1,7 @@ import { ipcMain, dialog, app } from 'electron' import log from 'electron-log' -export function createBridge({ serverPort }: { serverPort: number }) { - ipcMain.handle('readServerUrl', async () => { - return `http://localhost:${serverPort}` - }) +export function createBridge() { ipcMain.handle('sendFatalError', async (_ev, message: string) => { log.error(message) await dialog.showMessageBox({ diff --git a/desktop/index.ts b/desktop/index.ts index 3e6b5b8e..aabeaf70 100644 --- a/desktop/index.ts +++ b/desktop/index.ts @@ -1,15 +1,10 @@ import { app, dialog, BrowserWindow } from 'electron' -import { electronApp, optimizer } from '@electron-toolkit/utils' +import { electronApp, optimizer } from '@electron-toolkit/utils' import { createWindow } from './window' import { createBridge } from './bridge' -import { is } from '@electron-toolkit/utils' import { join } from 'path' import log from 'electron-log' -import * as system from './system' -import * as server from './server' -import * as python from './python' import * as settings from './settings' -import * as resources from './resources' // This method will be called when Electron has finished // initialization and is ready to create browser windows. @@ -17,19 +12,10 @@ import * as resources from './resources' app.whenReady().then(async () => { log.info('# Start application') electronApp.setAppUserModelId(settings.APP_USER_MODEL_ID) - const serverPort = await system.findPort() log.info('## Start client') - createBridge({ serverPort }) - createWindow() - - if (!is.dev) { - log.info('## Start server') - await resources.ensureRunner() - await python.ensurePython() - await python.ensureLibraries() - await server.runServer({ serverPort }) - } + createBridge() + await createWindow() }) // Default open or close DevTools by F12 in development diff --git a/desktop/preload/index.ts b/desktop/preload/index.ts index f034ad57..f1912d5b 100644 --- a/desktop/preload/index.ts +++ b/desktop/preload/index.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('opendataeditor', { - readServerUrl: () => ipcRenderer.invoke('readServerUrl'), sendFatalError: (message: string) => ipcRenderer.invoke('sendFatalError', message), openDirectoryDialog: () => ipcRenderer.invoke('openDirectoryDialog'), + ensureLogs: (callback: any) => ipcRenderer.on('ensureLogs', (_event, message: string) => callback(message)) }) diff --git a/desktop/python.ts b/desktop/python.ts index eab6abbf..5144d5af 100644 --- a/desktop/python.ts +++ b/desktop/python.ts @@ -6,20 +6,22 @@ import log from 'electron-log' import toml from 'toml' import * as system from './system' -export async function ensurePython() { - log.info('[ensurePython]', { path: settings.APP_PYTHON }) +export async function ensurePythonVirtualEnvironment() { + // When running, ODE will ensure that a virtual environment for dependencies exists + log.info('[ensurePythonVirtualEnvironment]', { path: settings.APP_PYTHON_VENV }) let message = 'existed' - if (!fs.existsSync(settings.APP_PYTHON)) { - await system.execFile(settings.PYTHON_SOURCE, ['-m', 'venv', settings.APP_PYTHON]) + if (!fs.existsSync(settings.APP_PYTHON_VENV)) { + await system.execFile(settings.PYTHON_SOURCE, ['-m', 'venv', settings.APP_PYTHON_VENV]) message = 'created' } - log.info('[ensurePython]', { message }) + log.info('[ensurePythonVirtualEnvironment]', { message }) } -export async function ensureLibraries() { - log.info('[ensureLibraries]') +export async function ensurePythonRequirements() { + // When running, ODE will ensure that all python dependencies are installed. + log.info('[ensurePythonRequirements]') const required = await readRequiredLibraries() const installed = await readInstalledLibraries() @@ -35,7 +37,7 @@ export async function ensureLibraries() { ...missing, ]) - log.info('[ensureLibraries]', { missing }) + log.info('[ensurePythonRequirements]', { missing }) } export async function readRequiredLibraries() { diff --git a/desktop/resources.ts b/desktop/resources.ts index c31fd6b2..6c2750ec 100644 --- a/desktop/resources.ts +++ b/desktop/resources.ts @@ -3,18 +3,20 @@ import fsp from 'fs/promises' import * as settings from './settings' import log from 'electron-log' -export async function ensureRunner() { - log.info('[ensureRunner]', { path: settings.APP_RUNNER }) +export async function ensurePython() { + // ODE builds a Python 3.10 distribution and ships it with the app. + // It is generated when running make build + log.info('[ensurePython]', { path: settings.APP_PYTHON }) let message = 'existed' - if (!fs.existsSync(settings.APP_RUNNER)) { - await fsp.mkdir(settings.APP_RUNNER, { recursive: true }) - await fsp.cp(settings.DIST_RUNNER, settings.APP_RUNNER, { + if (!fs.existsSync(settings.APP_PYTHON)) { + await fsp.mkdir(settings.APP_PYTHON, { recursive: true }) + await fsp.cp(settings.DIST_PYTHON, settings.APP_PYTHON, { recursive: true, verbatimSymlinks: true, }) message = 'created' } - log.info('[ensureRunner]', { message }) + log.info('[ensurePython]', { message }) } diff --git a/desktop/server.ts b/desktop/server.ts index 2bc04831..9b81eef2 100644 --- a/desktop/server.ts +++ b/desktop/server.ts @@ -2,13 +2,13 @@ import { spawnFile } from './system' import * as settings from './settings' import log from 'electron-log' -export async function runServer({ serverPort }: { serverPort: number }) { - log.info('[runServer]', { serverPort }) +export async function runServer() { + log.info(`[Run FastAPI server on port 4040`) - // Start server + // Start production server const proc = spawnFile( settings.PYTHON_TARGET, - ['-m', 'server', settings.APP_TMP, '--port', serverPort.toString()], + ['-m', 'server', settings.APP_TMP, '--port', `4040`], process.resourcesPath ) diff --git a/desktop/settings.ts b/desktop/settings.ts index 94097f38..235013b8 100644 --- a/desktop/settings.ts +++ b/desktop/settings.ts @@ -4,20 +4,17 @@ import { join } from 'path' export const WIN = process.platform === 'win32' export const HOME = os.homedir() -export const PORT_DEV = 4040 -export const PORT_PROD = 4444 - export const DIST = process.resourcesPath -export const DIST_RUNNER = join(DIST, 'runner') +export const DIST_PYTHON = join(DIST, 'runner') export const DIST_SERVER = join(DIST, 'server') export const APP_NAME = 'opendataeditor' export const APP_USER_MODEL_ID = 'org.opendataeditor' export const APP_HOME = join(HOME, `.${APP_NAME}`) -export const APP_RUNNER = join(APP_HOME, 'runner') -export const APP_PYTHON = join(APP_HOME, 'python') +export const APP_PYTHON = join(APP_HOME, 'runner') +export const APP_PYTHON_VENV = join(APP_HOME, 'python') // APP_TMP will be the folder to upload files the first time the user opens the Application export const APP_TMP = join(APP_HOME, 'tmp') -export const PYTHON_SOURCE = join(APP_RUNNER, WIN ? 'python.exe' : 'bin/python3') -export const PYTHON_TARGET = join(APP_PYTHON, WIN ? 'Scripts\\python.exe' : 'bin/python3') +export const PYTHON_SOURCE = join(APP_PYTHON, WIN ? 'python.exe' : 'bin/python3') +export const PYTHON_TARGET = join(APP_PYTHON_VENV, WIN ? 'Scripts\\python.exe' : 'bin/python3') diff --git a/desktop/system.ts b/desktop/system.ts index af45f19e..cbce1dcf 100644 --- a/desktop/system.ts +++ b/desktop/system.ts @@ -1,22 +1,8 @@ import cp from 'child_process' import util from 'util' import log from 'electron-log' -import portfinder from 'portfinder' -import { is } from '@electron-toolkit/utils' -import * as settings from './settings' const execFilePromise = util.promisify(cp.execFile) -export async function findPort() { - log.info('[findPort]') - - const port = !is.dev - ? await portfinder.getPortPromise({ port: settings.PORT_PROD }) - : settings.PORT_DEV - - log.info('[findPort]', { port }) - return port -} - export async function execFile(path: string, args: string[], cwd?: string) { log.info('[execFile]', { path, args, cwd }) diff --git a/desktop/window.ts b/desktop/window.ts index 10ee94ab..5d232d3f 100644 --- a/desktop/window.ts +++ b/desktop/window.ts @@ -1,16 +1,35 @@ import { shell, BrowserWindow } from 'electron' + import { resolve, join } from 'path' import { is } from '@electron-toolkit/utils' +import log from 'electron-log' +import * as server from './server' +import * as python from './python' +import * as resources from './resources' +import EventEmitter from 'events' + +const loadingEvents = new EventEmitter() + // @ts-ignore import icon from './assets/icon.png?asset' -export function createWindow() { +export async function createWindow() { // Create the browser window. + var splashWindow = new BrowserWindow({ + width: 500, + height: 500, + frame: false, + alwaysOnTop: true, + resizable: false, + autoHideMenuBar: true, + ...(process.platform === 'linux' ? { icon } : {}), + webPreferences: { + preload: join(__dirname, 'preload', 'index.js'), + }, + }); + const mainWindow = new BrowserWindow({ - // width: 900, - // height: 670, show: false, - // alwaysOnTop: true, autoHideMenuBar: true, ...(process.platform === 'linux' ? { icon } : {}), webPreferences: { @@ -18,23 +37,41 @@ export function createWindow() { }, }) - mainWindow.on('ready-to-show', () => { - mainWindow.maximize() - mainWindow.show() - }) - mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(resolve(__dirname, '..', 'client', 'index.html')) + loadingEvents.on('finished', () => { + splashWindow.close(); + log.info('Opening index.html') + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(resolve(__dirname, '..', 'client', 'index.html')) + } + mainWindow.maximize() + mainWindow.show(); + }) + + // Open the DevTools. + // mainWindow.webContents.openDevTools() + + if (!is.dev) { + log.info('Opening loading.html') + splashWindow.loadFile(resolve(__dirname, '..', 'client', 'loading.html')) + splashWindow.center() + log.info('## Start server') + await resources.ensurePython() + await python.ensurePythonVirtualEnvironment() + splashWindow?.webContents.send('ensureLogs', "python") + await python.ensurePythonRequirements() + splashWindow?.webContents.send('ensureLogs', "requirements") + await server.runServer() + splashWindow?.webContents.send('ensureLogs', "server") } - return mainWindow + loadingEvents.emit('finished') } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2f87496c..16c9d4d2 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ outDir: 'build/client', rollupOptions: { input: { + loading: 'client/loading.html', index: 'client/index.html', }, }, diff --git a/package.json b/package.json index e86ead11..58c2b834 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@fontsource/roboto": "5.0.5", "@fontsource/roboto-mono": "5.0.5", "@inovua/reactdatagrid-community": "5.10.1", - "@modyfi/vite-plugin-yaml": "1.0.4", + "@modyfi/vite-plugin-yaml": "1.1.0", "@monaco-editor/react": "4.5.1", "@mui/icons-material": "5.14.1", "@mui/material": "5.14.2", @@ -66,8 +66,8 @@ "@types/validator": "13.7.17", "@typescript-eslint/eslint-plugin": "6.7.3", "@typescript-eslint/parser": "6.7.3", - "@vitejs/plugin-react": "4.0.4", - "@vitest/coverage-v8": "0.34.4", + "@vitejs/plugin-react": "4.3.1", + "@vitest/coverage-v8": "1.6.0", "ahooks": "3.7.8", "classnames": "2.3.2", "concurrently": "8.2.0", @@ -75,10 +75,10 @@ "dayjs": "1.11.9", "delay": "6.0.0", "dirty-json": "0.9.2", - "electron": "26.2.1", - "electron-builder": "24.6.4", - "electron-updater": "6.1.4", - "electron-vite": "1.0.28", + "electron": "31.0.1", + "electron-builder": "^24.13.3", + "electron-updater": "6.2.1", + "electron-vite": "2.1.0", "eslint": "8.45.0", "fast-deep-equal": "3.1.3", "husky": "8.0.3", @@ -101,8 +101,8 @@ "validator": "13.9.0", "vega": "5.25.0", "vega-lite": "5.14.1", - "vite": "4.4.9", - "vitest": "0.34.4", + "vite": "5.3.1", + "vitest": "1.6.0", "zustand": "4.3.9" }, "prettier": {