diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 0000000000..e99c7f59ef --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js new file mode 100644 index 0000000000..dc1487b35b --- /dev/null +++ b/ui/.eslintrc.js @@ -0,0 +1,27 @@ +module.exports = { + env: { + es2020: true, + node: true, + }, + extends: ['prettier', 'plugin:prettier/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'prettier'], + rules: { + '@typescript-eslint/no-empty-interface': 0, + 'prettier/prettier': [ + 'error', + { + semi: false, + endOfLine: 'auto', + singleQuote: true, + tabWidth: 4, + useTabs: false, + trailingComma: 'es5', + }, + ], + }, +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000000..9ebad492ef --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,29 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist + +# misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.eslintcache +.swc + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +node_modules + +daisy-pipeline +.vscode/settings.json diff --git a/ui/.gitrepo b/ui/.gitrepo new file mode 100644 index 0000000000..9132e61757 --- /dev/null +++ b/ui/.gitrepo @@ -0,0 +1,11 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme +; +[subrepo] + remote = git@github.com:daisy/pipeline-ui + branch = main + commit = 7e007fd55bc983231eeeac9612b638caead93d38 + parent = bb1c2569df532c753759b136dc222e4b78791567 + cmdver = 0.3.1 diff --git a/ui/.husky/.gitignore b/ui/.husky/.gitignore new file mode 100644 index 0000000000..31354ec138 --- /dev/null +++ b/ui/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/ui/.husky/commit-msg b/ui/.husky/commit-msg new file mode 100755 index 0000000000..0bd658f496 --- /dev/null +++ b/ui/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install commitlint --edit "$1" diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit new file mode 100755 index 0000000000..36af219892 --- /dev/null +++ b/ui/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged diff --git a/ui/.swcrc b/ui/.swcrc new file mode 100644 index 0000000000..575007bd3e --- /dev/null +++ b/ui/.swcrc @@ -0,0 +1,21 @@ +{ + "sourceMaps": true, + "jsc": { + "parser": { + "target": "es2021", + "syntax": "typescript", + "jsx": true, + "tsx": true, + "dynamicImport": true, + "allowJs": true + }, + "transform": { + "react": { + "pragma": "React.createElement", + "pragmaFrag": "React.Fragment", + "throwIfNamespace": true, + "runtime": "automatic" + } + } + } +} \ No newline at end of file diff --git a/ui/LICENSE b/ui/LICENSE new file mode 100644 index 0000000000..0fa388ed84 --- /dev/null +++ b/ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Romain Deltour + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000000..20b12996bc --- /dev/null +++ b/ui/README.md @@ -0,0 +1,29 @@ +# pipeline-ui +A user interface for the DAISY Pipeline 2 + +## Features + +* Easy-to-use desktop application for the powerful [DAISY Pipeline](http://daisy.github.io/pipeline/) engine +* Run multiple jobs in a tabbed interface +* High contrast dark mode available +* Basic [keyboard shortcuts](#keyboard-shortcuts) + + +## Usage notes + +* Download and install the latest [release](https://github.com/daisy/pipeline-ui/releases) +* Start the Pipeline App and wait for the Pipeline engine to start +* Choose a script and fill out the appropriate fields +* Run the job and observe its progress and results + +## Keyboard shortcuts + +Use these shortcuts with `Alt + Shift` on Windows or `Control + Opt` on Mac + +* `0-9` to access the first 10 tabs quickly (1 = first tab, 0 = tenth tab) +* `R` to run a job +* `N` to add a new job + +## Other + +See the [developer documentation](https://github.com/daisy/pipeline-ui/wiki/Developer-documentation) \ No newline at end of file diff --git a/ui/app.config.js b/ui/app.config.js new file mode 100644 index 0000000000..fba298b656 --- /dev/null +++ b/ui/app.config.js @@ -0,0 +1,43 @@ +const { + devServer, + devTempBuildFolder, + name: NAME, + author: AUTHOR, + version: VERSION, + displayName: TITLE, + description: DESCRIPTION, +} = require('./package.json') + +exports.APP_CONFIG = { + APP_ID: `org.daisy.${NAME}`.toLowerCase(), + NAME, + TITLE, + AUTHOR, + VERSION, + DESCRIPTION, + + MAIN: { + WINDOW: { + WIDTH: 1500, + HEIGHT: 1000, + }, + }, + + RENDERER: { + DEV_SERVER: { + URL: devServer, + }, + }, + + FOLDERS: { + ENTRY_POINTS: { + MAIN: './src/main/index.ts', + BRIDGE: './src/renderer/bridge/index.ts', + RENDERER: './src/renderer/index.tsx', + }, + + INDEX_HTML: 'src/renderer/index.html', + RESOURCES: 'src/resources', + DEV_TEMP_BUILD: devTempBuildFolder, + }, +} diff --git a/ui/bin/constants/colors.js b/ui/bin/constants/colors.js new file mode 100644 index 0000000000..7a618de31f --- /dev/null +++ b/ui/bin/constants/colors.js @@ -0,0 +1,13 @@ +exports.COLORS = { + RED: '\x1b[31m', + RESET: '\x1b[0m', + GRAY: '\x1b[90m', + BLUE: '\x1b[34m', + CYAN: '\x1b[36m', + GREEN: '\x1b[32m', + WHITE: '\x1b[37m', + YELLOW: '\x1b[33m', + MAGENTA: '\x1b[35m', + LIGHT_GRAY: '\x1b[37m', + SOFT_GRAY: '\x1b[38;5;244m', +} diff --git a/ui/bin/constants/index.js b/ui/bin/constants/index.js new file mode 100644 index 0000000000..140e540f8b --- /dev/null +++ b/ui/bin/constants/index.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('./colors'), +} diff --git a/ui/bin/modules/postbuild/index.js b/ui/bin/modules/postbuild/index.js new file mode 100644 index 0000000000..7ef80ca59f --- /dev/null +++ b/ui/bin/modules/postbuild/index.js @@ -0,0 +1,34 @@ +const { writeFile } = require('fs/promises') +const { resolve } = require('path') + +const packageJSON = require('../../../package.json') + +async function createPackageJSONDistVersion() { + const { + main, + scripts, + devDependencies, + devTempBuildFolder, + ...restOfPackageJSON + } = packageJSON + + const packageJSONDistVersion = { + main: main?.split('/')?.reverse()?.[0] || 'index.js', + ...restOfPackageJSON, + } + + try { + await writeFile( + resolve(devTempBuildFolder, 'package.json'), + JSON.stringify(packageJSONDistVersion, null, 2) + ) + } catch ({ message }) { + console.log(` + 🛑 Something went wrong!\n + 🧐 There was a problem creating the package.json dist version...\n + 👀 Error: ${message} + `) + } +} + +createPackageJSONDistVersion() diff --git a/ui/bin/modules/release/index.js b/ui/bin/modules/release/index.js new file mode 100644 index 0000000000..b74f41d61d --- /dev/null +++ b/ui/bin/modules/release/index.js @@ -0,0 +1,75 @@ +const { writeFile } = require('fs/promises') +const { resolve } = require('path') +const open = require('open') + +const { extractOwnerAndRepoFromGitRemoteURL } = require('./utils') +const { checkValidations } = require('./validations') +const packageJSON = require('../../../package.json') +const { question, exec } = require('../../utils') +const { COLORS } = require('../../constants') + +async function makeRelease() { + console.clear() + + const { version } = packageJSON + + const newVersion = await question( + `Enter a new version: ${COLORS.SOFT_GRAY}(current is ${version})${COLORS.RESET} ` + ) + + if (checkValidations({ version, newVersion })) { + return + } + + packageJSON.version = newVersion + + try { + console.log( + `${COLORS.CYAN}> Updating package.json version...${COLORS.RESET}` + ) + + await writeFile( + resolve('package.json'), + JSON.stringify(packageJSON, null, 2) + ) + + console.log(`\n${COLORS.GREEN}Done!${COLORS.RESET}\n`) + console.log(`${COLORS.CYAN}> Trying to release it...${COLORS.RESET}`) + + exec( + [ + `git commit -am v${newVersion}`, + `git tag v${newVersion}`, + `git push`, + `git push --tags`, + ], + { + inherit: true, + } + ) + + const [repository] = exec([`git remote get-url --push origin`]) + const ownerAndRepo = extractOwnerAndRepoFromGitRemoteURL(repository) + + console.log( + `${COLORS.CYAN}> Opening the repository releases page...${COLORS.RESET}` + ) + + await open(`https://github.com/${ownerAndRepo}/releases`) + + console.log( + `${COLORS.CYAN}> Opening the repository actions page...${COLORS.RESET}` + ) + + await open(`https://github.com/${ownerAndRepo}/actions`) + + console.log(`\n${COLORS.GREEN}Done!${COLORS.RESET}\n`) + } catch ({ message }) { + console.log(` + 🛑 Something went wrong!\n + 👀 Error: ${message} + `) + } +} + +makeRelease() diff --git a/ui/bin/modules/release/utils/extractors.js b/ui/bin/modules/release/utils/extractors.js new file mode 100644 index 0000000000..b4c10a38d2 --- /dev/null +++ b/ui/bin/modules/release/utils/extractors.js @@ -0,0 +1,6 @@ +exports.extractOwnerAndRepoFromGitRemoteURL = (url) => { + return url + ?.replace(/^git@github.com:|.git$/gims, '') + ?.replace(/^https:\/\/github.com\/|.git$/gims, '') + ?.trim() +} diff --git a/ui/bin/modules/release/utils/index.js b/ui/bin/modules/release/utils/index.js new file mode 100644 index 0000000000..ca93616523 --- /dev/null +++ b/ui/bin/modules/release/utils/index.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('./extractors'), +} diff --git a/ui/bin/modules/release/validations/index.js b/ui/bin/modules/release/validations/index.js new file mode 100644 index 0000000000..0c271cb9fb --- /dev/null +++ b/ui/bin/modules/release/validations/index.js @@ -0,0 +1,35 @@ +const semver = require('semver') + +const { COLORS } = require('../../../constants') + +exports.checkValidations = ({ version, newVersion }) => { + if (!newVersion) { + console.log(`${COLORS.RED}No version entered${COLORS.RESET}`) + + return true + } + + if (!semver.valid(newVersion)) { + console.log( + `${COLORS.RED}Version must have a semver format (${COLORS.SOFT_GRAY}x.x.x${COLORS.RESET} example: ${COLORS.GREEN}1.0.1${COLORS.RESET}${COLORS.RED})${COLORS.RESET}` + ) + + return true + } + + if (semver.ltr(newVersion, version)) { + console.log( + `${COLORS.RED}New version is lower than current version${COLORS.RESET}` + ) + + return true + } + + if (semver.eq(newVersion, version)) { + console.log( + `${COLORS.RED}New version is equal to current version${COLORS.RESET}` + ) + + return true + } +} diff --git a/ui/bin/utils/exec.js b/ui/bin/utils/exec.js new file mode 100644 index 0000000000..2dbc448e1b --- /dev/null +++ b/ui/bin/utils/exec.js @@ -0,0 +1,21 @@ +const { execSync } = require('child_process') +const { resolve } = require('path') + +function makeOptions(options) { + return { + stdio: options?.inherit ? 'inherit' : 'pipe', + cwd: resolve(), + encoding: 'utf8', + } +} + +exports.exec = (commands, options) => { + const outputs = [] + + for (const command of commands) { + const output = execSync(command, makeOptions(options)) + outputs.push(output) + } + + return outputs +} diff --git a/ui/bin/utils/index.js b/ui/bin/utils/index.js new file mode 100644 index 0000000000..de55d383b4 --- /dev/null +++ b/ui/bin/utils/index.js @@ -0,0 +1,4 @@ +module.exports = { + ...require('./question'), + ...require('./exec'), +} diff --git a/ui/bin/utils/question.js b/ui/bin/utils/question.js new file mode 100644 index 0000000000..9ddbe14639 --- /dev/null +++ b/ui/bin/utils/question.js @@ -0,0 +1,15 @@ +const Readline = require('readline') + +exports.question = (question) => { + const readline = Readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + readline.question(question, (answer) => { + readline.close() + resolve(answer) + }) + }) +} diff --git a/ui/buildtools/entitlements.mac.plist b/ui/buildtools/entitlements.mac.plist new file mode 100644 index 0000000000..661feb77a4 --- /dev/null +++ b/ui/buildtools/entitlements.mac.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + \ No newline at end of file diff --git a/ui/buildtools/notarize.js b/ui/buildtools/notarize.js new file mode 100644 index 0000000000..3b1ff13b73 --- /dev/null +++ b/ui/buildtools/notarize.js @@ -0,0 +1,24 @@ +const { APP_CONFIG } = require('../app.config') + +const { APP_ID } = APP_CONFIG + +require('dotenv').config() +const { notarize } = require('electron-notarize') + +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context + if (electronPlatformName !== 'darwin') { + return + } + + const appName = context.packager.appInfo.productFilename + + return await notarize({ + appBundleId: APP_ID, + appPath: `${appOutDir}/${appName}.app`, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_ID_PASS, + ascProvider: process.env.APPLE_ID_TEAM, + teamId: process.env.APPLE_ID_TEAM, + }) +} diff --git a/ui/commitlint.config.js b/ui/commitlint.config.js new file mode 100644 index 0000000000..dc483e9c7f --- /dev/null +++ b/ui/commitlint.config.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': () => [0, 'always', 200], + }, +} diff --git a/ui/electron-builder.js b/ui/electron-builder.js new file mode 100644 index 0000000000..bd2cedfbc8 --- /dev/null +++ b/ui/electron-builder.js @@ -0,0 +1,40 @@ +const { APP_CONFIG } = require('./app.config') + +const { APP_ID, AUTHOR, TITLE, DESCRIPTION, FOLDERS } = APP_CONFIG + +const CURRENT_YEAR = new Date().getFullYear() + +module.exports = { + appId: APP_ID, + productName: TITLE, + copyright: `Copyright © ${CURRENT_YEAR} — ${AUTHOR.name}`, + + directories: { + app: FOLDERS.DEV_TEMP_BUILD, + output: 'dist', + }, + + mac: { + icon: `${FOLDERS.RESOURCES}/icons/logo.icns`, + category: 'public.app-category.utilities', + identity: 'US Fund for DAISY (SAMG8AWD69)', + hardenedRuntime: true, + }, + + dmg: { + icon: false, + }, + + linux: { + category: 'Utilities', + synopsis: DESCRIPTION, + target: ['AppImage', 'deb', 'pacman', 'freebsd', 'rpm'], + }, + + win: { + icon: `${FOLDERS.RESOURCES}/icons/logo_256x256.png`, + target: ['nsis', 'portable', 'zip'], + }, + afterSign: 'buildtools/notarize.js', + asarUnpack: ['resources/daisy-pipeline'], +} diff --git a/ui/globals.d.ts b/ui/globals.d.ts new file mode 100644 index 0000000000..341e1b5c14 --- /dev/null +++ b/ui/globals.d.ts @@ -0,0 +1,8 @@ +declare module '*.css' +declare module '*.scss' +declare module '*.sass' +declare module '*.jpeg' +declare module '*.jpg' +declare module '*.png' +declare module '*.svg' +declare module '*.gif' diff --git a/ui/mockup.html b/ui/mockup.html new file mode 100644 index 0000000000..bedf9014b8 --- /dev/null +++ b/ui/mockup.html @@ -0,0 +1,313 @@ + + + + + DAISY Pipeline + + + + +
+
+
+ + +
+ +
+ + +
+
+ +
+
+
+ + +
+ +
+
+

DAISY 2.02 to EPUB 3

+

Convert a blah to a blegh

+
+ +
+ +
+
+

Required information

+
    +
  • +
    + + The package file of the input DTB. +
    +
    + file://path/to/file.xml + +
    +
  • +
  • +
    + + An audio file containing the narration. +
    +
    + file://path/to/file.mp3 + +
    +
  • +
  • +
    + + The package file of the input DTB. +
    +
    + file://path/to/file.xml + +
    +
  • +
  • +
    + + An audio file containing the narration. +
    +
    + file://path/to/file.mp3 + +
    +
  • +
  • +
    + + The package file of the input DTB. +
    +
    + file://path/to/file.xml + +
    +
  • +
  • +
    + + An audio file containing the narration. +
    +
    + file://path/to/file.mp3 + +
    +
  • +
  • +
    + + The package file of the input DTB. +
    +
    + file://path/to/file.xml + +
    +
  • +
  • +
    + + An audio file containing the narration. +
    +
    + file://path/to/file.mp3 + +
    +
  • +
+
+
+

Options

+
    +
  • +
    + + Creates an additional XYZ +
    + +
  • +
  • +
    + + Provide the results in a zip container +
    + +
  • +
+
+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000..ce85aa4097 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,119 @@ +{ + "displayName": "DAISY Pipeline", + "name": "pipeline-ui", + "description": "User interface for the DAISY Pipeline", + "version": "0.2.0-alpha", + "main": "./node_modules/.dev-temp-build/main.js", + "devTempBuildFolder": "./node_modules/.dev-temp-build", + "devServer": "http://localhost:4927", + "author": { + "name": "DAISY Consortium", + "email": "daisy-pipeline@mail.daisy.org" + }, + "contributors": [ + { + "name": "Marisa DeMeglio", + "email": "marisa.demeglio@gmail.com" + }, + { + "name": "Nicolas Pavie", + "email": "pavie.nicolas@gmail.com" + } + ], + "license": "MIT", + "scripts": { + "start": "electron .", + "build": "cross-env NODE_ENV=production yarn r clean webpack:main webpack:renderer", + "postbuild": "node ./bin/modules/postbuild/index.js permissions", + "predev": "cross-var \"rimraf $npm_package_devTempBuildFolder\"", + "dev": "concurrently \"cross-env BROWSER=none yarn dev:renderer\" \"yarn r server:wait permissions nodemon:start\"", + "dev:main": "cross-env NODE_ENV=development yarn r webpack:main start", + "dev:renderer": "cross-env NODE_ENV=development yarn r webpack:renderer webpack:serve", + "postinstall": "yarn r build install:deps", + "install:deps": "electron-builder install-app-deps", + "make:release": "node ./bin/modules/release/index.js", + "webpack:main": "webpack --config ./webpack/main.config.js", + "webpack:renderer": "webpack --config ./webpack/renderer.config.js", + "webpack:serve": "webpack serve --config ./webpack/renderer.config.js", + "server:wait": "cross-var \"wait-on $npm_package_devServer\"", + "nodemon:start": "nodemon --watch src/main --watch src/renderer/bridge --watch src/shared --ignore src/resources --ext \"*\" --exec yarn dev:main", + "predist": "yarn build", + "dist": "./node_modules/.bin/electron-builder", + "release": "electron-builder --publish always", + "clean": "rimraf dist", + "prepare": "husky install", + "r": "yarn run-s", + "permissions": "chmod -R +x ./node_modules/.dev-temp-build/resources" + }, + "dependencies": { + "@tanstack/react-query": "^4.2.1", + "@types/marked": "^4.0.7", + "decompress": "^4.2.1", + "electron-log": "^4.4.8", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.3", + "react-router-dom": "^6.3.0", + "react-tabs": "^5.1.0", + "remark-gfm": "^3.0.1" + }, + "devDependencies": { + "@commitlint/cli": "^17.0.3", + "@commitlint/config-conventional": "^17.0.3", + "@daltonmenezes/electron-devtools-installer": "^1.0.1", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", + "@svgr/webpack": "^6.3.1", + "@swc/cli": "^0.1.57", + "@swc/core": "^1.2.237", + "@types/decompress": "^4.2.4", + "@types/node": "^18.7.23", + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@typescript-eslint/eslint-plugin": "^5.33.1", + "@typescript-eslint/parser": "^5.33.1", + "concurrently": "^7.3.0", + "copy-webpack-plugin": "^11.0.0", + "cross-env": "^7.0.3", + "cross-var": "^1.1.0", + "css-loader": "^6.7.1", + "electron": "21.3.1", + "electron-builder": "23.6.0", + "electron-notarize": "^1.2.2", + "electron-react-devtools": "^0.5.3", + "eslint": "^8.22.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^4.2.1", + "file-loader": "^6.2.0", + "html-webpack-plugin": "^5.5.0", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "nodemon": "^2.0.19", + "npm-run-all": "^4.1.5", + "open": "^8.4.0", + "prettier": "^2.7.1", + "react-refresh": "^0.14.0", + "rimraf": "^3.0.2", + "sass": "^1.54.4", + "sass-loader": "^13.0.2", + "semver": "^7.3.7", + "simple-progress-webpack-plugin": "^2.0.0", + "style-loader": "^3.3.1", + "swc-loader": "^0.2.3", + "tsconfig-paths-webpack-plugin": "^4.0.0", + "typescript": "^4.7.4", + "typescript-plugin-css-modules": "^3.4.0", + "wait-on": "^6.0.1", + "webpack": "^5.74.0", + "webpack-cli": "^4.10.0", + "webpack-dev-server": "^4.10.0" + }, + "lint-staged": { + "*.{js,ts}": [ + "eslint --quiet --fix" + ] + }, + "eslintIgnore": [ + "dist" + ] +} diff --git a/ui/src/main/browser.ts b/ui/src/main/browser.ts new file mode 100644 index 0000000000..c85f13165d --- /dev/null +++ b/ui/src/main/browser.ts @@ -0,0 +1,12 @@ +import { ipcMain, shell } from 'electron' + +// helper functions +import { IPC_EVENT_openInBrowser } from '../shared/main-renderer-events' + +function setupOpenInBrowserEvents() { + // payload should be "/Path/to/folder" not file://.. + ipcMain.on(IPC_EVENT_openInBrowser, (event, payload) => { + shell.openExternal(payload) + }) +} +export { setupOpenInBrowserEvents } diff --git a/ui/src/main/clipboard.ts b/ui/src/main/clipboard.ts new file mode 100644 index 0000000000..1bec2be65a --- /dev/null +++ b/ui/src/main/clipboard.ts @@ -0,0 +1,21 @@ +import { ipcMain, dialog, BrowserWindow, shell, clipboard } from 'electron' +import { PLATFORM, ENVIRONMENT } from 'shared/constants' + +// helper functions +const { IPC_EVENT_copyToClipboard } = require('../shared/main-renderer-events') + +const copyToClipboard = (str) => { + clipboard.writeText(str) +} + +function setupClipboardEvents() { + // event.sender is Electron.WebContents + // const win = BrowserWindow.fromWebContents(event.sender) || undefined; + // const webcontent = webContents.fromId(payload.webContentID); // webcontents.id is identical + + ipcMain.on(IPC_EVENT_copyToClipboard, (event, payload) => { + // TODO + // but we don't need clipboard just yet + }) +} +export { setupClipboardEvents } diff --git a/ui/src/main/factories/app/index.ts b/ui/src/main/factories/app/index.ts new file mode 100644 index 0000000000..5adb2bdbb0 --- /dev/null +++ b/ui/src/main/factories/app/index.ts @@ -0,0 +1,2 @@ +export * from './instance' +export * from './setup' diff --git a/ui/src/main/factories/app/instance.ts b/ui/src/main/factories/app/instance.ts new file mode 100644 index 0000000000..68ba509ac5 --- /dev/null +++ b/ui/src/main/factories/app/instance.ts @@ -0,0 +1,7 @@ +import { app } from 'electron' + +export function makeAppWithSingleInstanceLock(fn: () => void) { + const isPrimaryInstance = app.requestSingleInstanceLock() + + !isPrimaryInstance ? app.quit() : fn() +} diff --git a/ui/src/main/factories/app/setup.ts b/ui/src/main/factories/app/setup.ts new file mode 100644 index 0000000000..ffeef28669 --- /dev/null +++ b/ui/src/main/factories/app/setup.ts @@ -0,0 +1,45 @@ +import { app, BrowserWindow } from 'electron' + +import installExtension, { + REACT_DEVELOPER_TOOLS, +} from '@daltonmenezes/electron-devtools-installer' + +import { PLATFORM, ENVIRONMENT } from 'shared/constants' + +export async function makeAppSetup(createWindow: () => Promise) { + if (ENVIRONMENT.IS_DEV) { + await installExtension(REACT_DEVELOPER_TOOLS, { + forceDownload: false, + }) + } + + let window = await createWindow() + + app.on('activate', async () => + !BrowserWindow.getAllWindows().length + ? (window = await createWindow()) + : BrowserWindow.getAllWindows() + ?.reverse() + .forEach((window) => window.restore()) + ) + + app.on('web-contents-created', (_, contents) => + contents.on( + 'will-navigate', + (event, _) => !ENVIRONMENT.IS_DEV && event.preventDefault() + ) + ) + + app.on('window-all-closed', () => { + PLATFORM.IS_MAC && app.dock.hide() + }) + + return window +} + +PLATFORM.IS_LINUX && app.disableHardwareAcceleration() + +app.commandLine.appendSwitch('force-color-profile', 'srgb') + +process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' +delete process.env.ELECTRON_ENABLE_SECURITY_WARNINGS diff --git a/ui/src/main/factories/index.ts b/ui/src/main/factories/index.ts new file mode 100644 index 0000000000..35a522be7a --- /dev/null +++ b/ui/src/main/factories/index.ts @@ -0,0 +1,5 @@ +export * from './ipcs/register-window-creation' +export * from './ipcs/pipeline2' +export * from './ipcs/settings' +export * from './windows/create' +export * from './app' diff --git a/ui/src/main/factories/ipcs/file.ts b/ui/src/main/factories/ipcs/file.ts new file mode 100644 index 0000000000..df91286c78 --- /dev/null +++ b/ui/src/main/factories/ipcs/file.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron' +import { IPC } from 'shared/constants' +import { writeFile } from 'fs/promises' +import { info } from 'electron-log' +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' +import { existsSync, mkdirSync } from 'fs' + +import decompress, { File as DecompressedFile } from 'decompress' + +export function registerFileIPC() { + ipcMain.handle( + IPC.FILE.SAVE, + async (event, buffer: ArrayBuffer, pathFileURL: string) => { + const systemPath = fileURLToPath(pathFileURL) + console.log('saving to ', systemPath) + if (!existsSync(dirname(systemPath))) { + mkdirSync(dirname(systemPath), { recursive: true }) + } + return writeFile(systemPath, Buffer.from(buffer)) + .catch((e) => { + info( + 'An error occured writing file ', + pathFileURL, + ' : ', + e + ) + return false + }) + .then((v) => { + return true + }) + } + ) + + ipcMain.handle( + IPC.FILE.UNZIP, + async (event, buffer: ArrayBuffer, pathFileURL: string) => { + const systemPath = fileURLToPath(pathFileURL) + console.log('unzipping to ', systemPath) + if (!existsSync(systemPath)) { + mkdirSync(systemPath, { recursive: true }) + } + return decompress(Buffer.from(buffer), systemPath) + .then((files: DecompressedFile[]) => + files.map((file) => resolve(systemPath, file.path)) + ) + .catch((error) => { + return null + }) + } + ) + + ipcMain.on(IPC.FILE.OPEN, (event, path) => { + // Request the opening of a file in the system + }) +} diff --git a/ui/src/main/factories/ipcs/pipeline2.ts b/ui/src/main/factories/ipcs/pipeline2.ts new file mode 100644 index 0000000000..e461fbefea --- /dev/null +++ b/ui/src/main/factories/ipcs/pipeline2.ts @@ -0,0 +1,627 @@ +import { app, ipcMain } from 'electron' +import { resolve, delimiter, relative } from 'path' +import { + Webservice, + PipelineStatus, + PipelineState, + ApplicationSettings, +} from 'shared/types' +import { ENVIRONMENT, IPC } from 'shared/constants' +import { setTimeout } from 'timers/promises' +import { spawn, ChildProcessWithoutNullStreams } from 'child_process' +import { existsSync, mkdir, mkdirSync } from 'fs' + +import { getAvailablePort, Pipeline2Error, walk } from './utils' + +import { resolveUnpacked } from 'shared/utils' + +import { info, error } from 'electron-log' + +import { request as httpRequest } from 'http' +import { pathToFileURL } from 'url' +// NP : for future use if we want to use the app +// to also manage a pipeline behind https +//import { request as httpsRequest } from 'https' + +/** + * Properties for initializing ipc with the daisy pipeline 2 + * + */ +export interface Pipeline2IPCProps { + /** + * optional path of the local installation of the pipeline, + * + * defaults to the application resources/daisy-pipeline + */ + localPipelineHome?: string + + appDataFolder?: string + + logsFolder?: string + /** + * optional path to the java runtime + * + * defaults to the application resource/jre folder + */ + jrePath?: string + + /** + * Webservice configuration to use for embedded pipeline, + * + * defaults to a localhost managed configuration : + * ```js + * { + * host: "localhost" + * port: 0, // will search for an available port on the current host when calling launch() the first time + * path: "/ws" + * } + * ``` + * + */ + webservice?: Webservice + + /** + * + */ + onError?: (error: string) => void + + onMessage?: (message: string) => void +} + +/** + * Local DAISY Pipeline 2 management class + */ +export class Pipeline2IPC { + props: Pipeline2IPCProps + // Default state + state: PipelineState + stateListeners: Map void> = new Map< + string, + (data: PipelineState) => void + >() + runStateMonitor: boolean = true + + messages: Array + messagesListeners: Map void> = new Map< + string, + (data: string) => void + >() + + errors: Array + errorsListeners: Map void> = new Map< + string, + (data: string) => void + >() + + private instance?: ChildProcessWithoutNullStreams + /** + * + * @param parameters + */ + constructor(props?: Pipeline2IPCProps) { + const osAppDataFolder = app.getPath('userData') + this.props = { + localPipelineHome: + (props && props.localPipelineHome) ?? + resolveUnpacked('resources', 'daisy-pipeline'), + jrePath: + (props && props.jrePath) ?? + resolveUnpacked('resources', 'daisy-pipeline', 'jre'), + // Note : [49152 ; 65535] is the range of dynamic port, 0 is reserved for error case + webservice: (props && props.webservice) ?? { + host: '127.0.0.1', // Note : localhost resolve as ipv6 ':::' in nodejs, but we need ipv4 for the pipeline + port: 0, + path: '/ws', + }, + appDataFolder: + (props && props.appDataFolder) ?? app.getPath('userData'), + logsFolder: + (props && props.logsFolder) ?? + resolve(app.getPath('userData'), 'pipeline-logs'), + onError: (props && props.onError) || error, + onMessage: (props && props.onMessage) || info, + } + this.instance = null + this.errors = [] + this.messages = [] + this.setState({ + status: PipelineStatus.STOPPED, + }) + this.stateMonitor = this.stateMonitor.bind(this) + } + + /** + * Monitor function to watch the webservice state + */ + async stateMonitor(refreshTimerInMillisecondes = 1000) { + while (this.runStateMonitor) { + await setTimeout(refreshTimerInMillisecondes).then(async () => { + return new Promise((resolve, reject) => { + const options = { + host: this.props.webservice.host, + port: this.props.webservice.port, + path: this.props.webservice.path + '/alive', + } + const callback = (response) => { + var data = '' + response.on('data', (chunk) => { + data += chunk + }) + response.on('end', () => { + resolve(data) + }) + } + const req = httpRequest(options, callback) + req.on('error', (errorVal) => { + reject(errorVal) + }) + req.end() + }) + .then((value) => { + if (this.state.status != PipelineStatus.RUNNING) { + this.setState({ + status: PipelineStatus.RUNNING, + }) + } + }) + .catch(() => { + // if pipeline went from running to offline + if (this.state.status === PipelineStatus.RUNNING) { + this.setState({ + status: PipelineStatus.STOPPED, + }) + } + }) + }) + } + } + + /** + * Change the webservice configuration (stop and restart the server if so) + * @param webservice + */ + updateWebservice(webservice: Webservice) { + this.stop().then(() => { + this.props.webservice = webservice + this.launch() + }) + } + + setState(newState: { + runningWebservice?: Webservice + status?: PipelineStatus + }) { + this.state = { + runningWebservice: + newState.runningWebservice ?? + (this.state && this.state.runningWebservice), + status: + newState.status ?? + ((this.state && this.state.status) || PipelineStatus.STOPPED), + } + for (const [callerID, callback] of this.stateListeners) { + callback(this.state) + } + } + pushMessage(message: string) { + this.messages.push(message) + if (this.props.onMessage) { + this.props.onMessage(message) + } + this.messagesListeners.forEach((callback) => { + callback(message) + }) + } + pushError(message: string) { + this.errors.push(message) + if (this.props.onError) { + this.props.onError(message) + } + this.errorsListeners.forEach((callback) => { + callback(message) + }) + } + + /** + * Launch a local instance of the pipeline using the current webservice settings + */ + async launch(): Promise { + if (!this.instance || this.state.status == PipelineStatus.STOPPED) { + this.setState({ + status: PipelineStatus.STARTING, + }) + if ( + this.props.webservice.port !== undefined && + this.props.webservice.port === 0 + ) { + info('Searching for an valid port') + try { + await getAvailablePort( + 49152, + 65535, + this.props.webservice.host + ) + .then( + ((port) => { + this.props.webservice.port = port + // + if ( + this.props.jrePath === null || + !existsSync(this.props.jrePath) + ) { + throw new Pipeline2Error( + 'NO_JRE', + 'No jre found to launch the pipeline' + ) + } + + if ( + this.props.localPipelineHome === null || + !existsSync(this.props.jrePath) + ) { + throw new Pipeline2Error( + 'NO_PIPELINE', + 'No pipeline installation found' + ) + } + }).bind(this) + ) + .catch((err) => { + // propagate exception for now + throw err + }) + } catch (error) { + this.pushError(error) + // no port available, try to use the usual 8181 + this.props.webservice.port = 8181 + } + } + info( + `Launching pipeline on ${this.props.webservice.host}:${this.props.webservice.port}` + ) + let ClassFolders = [ + resolve(this.props.localPipelineHome, 'system'), + resolve(this.props.localPipelineHome, 'modules'), + ] + let jarFiles = ClassFolders.reduce( + (acc: Array, path: string) => { + existsSync(path) && + acc.push( + ...walk(path, (name) => { + return name.endsWith('.jar') + }) + ) + return acc + }, + [] + ) + let relativeJarFiles = jarFiles.reduce( + (acc: Array, path: string) => { + let relativeDirPath = relative( + this.props.localPipelineHome, + path + ) + if (!acc.includes(relativeDirPath)) { + acc.push(relativeDirPath) + } + return acc + }, + [] + ) + + let JavaOptions = [ + '-server', + '-Dcom.sun.management.jmxremote', + '--add-opens=java.base/java.security=ALL-UNNAMED', + '--add-opens=java.base/java.net=ALL-UNNAMED', + '--add-opens=java.base/java.lang=ALL-UNNAMED', + '--add-opens=java.base/java.util=ALL-UNNAMED', + '--add-opens=java.naming/javax.naming.spi=ALL-UNNAMED', + '--add-opens=java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED', + '--add-exports=java.base/sun.net.www.protocol.http=ALL-UNNAMED', + '--add-exports=java.base/sun.net.www.protocol.https=ALL-UNNAMED', + '--add-exports=java.base/sun.net.www.protocol.jar=ALL-UNNAMED', + '--add-exports=jdk.xml.dom/org.w3c.dom.html=ALL-UNNAMED', + '--add-exports=jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED', + ] + + let SystemProps = [ + '-Dorg.daisy.pipeline.properties="' + + resolve( + this.props.localPipelineHome, + 'etc', + 'pipeline.properties' + ) + + '"', + // Logback configuration file + '-Dlogback.configurationFile=' + + pathToFileURL( + resolve( + this.props.localPipelineHome, + 'etc', + 'config-logback.xml' + ) + ).href + + '', + // XMLCalabash base configuration file + '-Dorg.daisy.pipeline.xproc.configuration="' + + resolve( + this.props.localPipelineHome, + 'etc', + 'config-calabash.xml' + ).replaceAll('\\', '/') + + '"', + // Updater configuration + '-Dorg.daisy.pipeline.updater.bin="' + + resolve( + this.props.localPipelineHome, + 'updater', + 'pipeline-updater' + ).replaceAll('\\', '/') + + '"', + '-Dorg.daisy.pipeline.updater.deployPath="' + + this.props.localPipelineHome.replaceAll('\\', '/') + + '/"', + '-Dorg.daisy.pipeline.updater.releaseDescriptor="' + + resolve( + this.props.localPipelineHome, + 'etc', + 'releaseDescriptor.xml' + ).replaceAll('\\', '/') + + '"', + // Workaround for encoding bugs on Windows + '-Dfile.encoding=UTF8', + // to make ${org.daisy.pipeline.data}, ${org.daisy.pipeline.logdir} and ${org.daisy.pipeline.mode} + // available in config-logback.xml and felix.properties + // note that config-logback.xml is the only place where ${org.daisy.pipeline.mode} is used + '-Dorg.daisy.pipeline.data=' + this.props.appDataFolder + '', + '-Dorg.daisy.pipeline.logdir=' + this.props.logsFolder + '', + '-Dorg.daisy.pipeline.mode=webservice', + '-Dorg.daisy.pipeline.ws.localfs=true', + '-Dorg.daisy.pipeline.ws.authentication=false', + '-Dorg.daisy.pipeline.ws.host=' + this.props.webservice.host, + '-Dorg.daisy.pipeline.ws.cors=true', + ] + if (this.props.webservice.path) { + SystemProps.push( + '-Dorg.daisy.pipeline.ws.path=' + this.props.webservice.path + ) + } + if (this.props.webservice.port) { + SystemProps.push( + '-Dorg.daisy.pipeline.ws.port=' + this.props.webservice.port + ) + } + + if ( + !existsSync(this.props.appDataFolder) && + mkdirSync(this.props.appDataFolder, { recursive: true }) + ) { + this.pushMessage(`${this.props.appDataFolder} created`) + } else { + this.pushMessage( + `Using existing ${this.props.appDataFolder} as pipeline data folder` + ) + } + + if ( + !existsSync(this.props.logsFolder) && + mkdirSync(this.props.logsFolder, { recursive: true }) + ) { + this.pushMessage(`${this.props.logsFolder} created`) + } else { + this.pushMessage( + `Using existing ${this.props.logsFolder} for pipeline logs` + ) + } + // avoid using bat to control the runner ? + // Spawn pipeline process + let command = resolve(this.props.jrePath, 'bin', 'java') + let args = [ + ...JavaOptions, + ...SystemProps, + '-classpath', + `"${delimiter}${relativeJarFiles.join(delimiter)}${delimiter}"`, + 'org.daisy.pipeline.webservice.impl.PipelineWebService', + ] + this.pushMessage( + `Launching the local pipeline with the following command : +${command} ${args.join(' ')}` + ) + this.instance = spawn(command, args, { + cwd: this.props.localPipelineHome, + }) + // NP Replace stdout analysis by webservice monitoring + this.instance.stdout.on('data', (data) => { + // Removing logging on nodejs side, + // as logging is already done in the pipeline side + // + // we might read the pipeline logs + // or check in the API if there is some logs entry point + }) + this.instance.stderr.on('data', (data) => { + // keep error logging in case of error raised by the pipeline instance + // NP : problem found on the pipeline, the webservice messages are also outputed to the err stream + // this.pushError(`${data.toString()}`) + }) + this.instance.on('exit', (code, signal) => { + let message = `Pipeline exiting with code ${code} and signal ${signal}` + this.setState({ + status: PipelineStatus.STOPPED, + }) + this.pushMessage(message) + }) + this.instance.on('close', (code: number, args: any[]) => { + let message = `Pipeline closing with code: ${code} args: ${args}` + this.setState({ + status: PipelineStatus.STOPPED, + }) + this.pushMessage(message) + }) + this.setState({ + status: PipelineStatus.STARTING, + runningWebservice: this.props.webservice, + }) + } + // Launch the async state monitoring loop + // this.stateMonitor() + return this.state + } + + /** + * Stopping the pipeline + */ + async stop(appIsClosing = false) { + this.runStateMonitor = false + if (appIsClosing) { + this.stateListeners.clear() + this.messagesListeners.clear() + this.errorsListeners.clear() + } + if (this.instance) { + info('closing pipeline') + let finished = false + finished = this.instance.kill() + if (!finished) { + this.instance.kill('SIGKILL') + } + this.setState({ + status: PipelineStatus.STOPPED, + }) + return + } + } + + /** + * Add a listener on state changes + * @param callerID an id to identify the caller + * @param callback function to run on new state + */ + registerStateListener( + callerID: string, + callback: (data: PipelineState) => void + ) { + this.stateListeners.set(callerID, callback) + } + + /** + * Remove a state listener + * @param callerID the id of the caller which had registered the listener + */ + removeStateListener(callerID: string) { + this.stateListeners.delete(callerID) + } + + /** + * Add a listener on the messages stack + * @param callerID the id of the element that register the listener + * @param callback the function to run when a new message is added on the stack + */ + registerMessagesListener( + callerID: string, + callback: (data: string) => void + ) { + this.messagesListeners.set(callerID, callback) + } + + /** + * Remove a listener on the messages stack + * @param callerID the id of the caller which had registered the listener + */ + removeMessageListener(callerID: string) { + this.messagesListeners.delete(callerID) + } + + /** + * Add a listener on the error messages stack + * @param callerID the id of the element that register the listener + * @param callback the function to run when a new error message is added on the stack + */ + registerErrorsListener(callerID: string, callback: (data: string) => void) { + this.errorsListeners.set(callerID, callback) + } + + /** + * Remove a listener on the error messages stack + * @param callerID the id of the caller which had registered the listener + */ + removeErrorsListener(callerID: string) { + this.errorsListeners.delete(callerID) + } +} + +/** + * Register the management of a local pipeline instance to IPC for communication with selected windows + * @returns the managed instance for supplemental bindings + */ +export function registerPipeline2ToIPC( + settings?: ApplicationSettings +): Pipeline2IPC { + // Instance managed through IPC calls within the app + let pipeline2instance = new Pipeline2IPC( + settings ? settings.localPipelineProps : undefined + ) + // Update the instance if the settings are being updated + ipcMain.on( + IPC.WINDOWS.SETTINGS.UPDATE, + (event, newSettings: ApplicationSettings) => { + info('pipeline has received settings update') + // Check if pipeline should be deactivated + if ( + newSettings.runLocalPipeline == false && + pipeline2instance.state.status != PipelineStatus.STOPPED + ) { + pipeline2instance.stop() + } else { + // TODO: restart the pipeline with updated settings if those have changed + // pipeline2instance.stop().then(() => { + // if (newSettings.localPipelineProps) { + // pipeline2instance.props = { + // ...pipeline2instance.props, + // ...newSettings.localPipelineProps, + // } + // } + // pipeline2instance.launch() + // }) + } + } + ) + // start the pipeline runner. + ipcMain.on(IPC.PIPELINE.START, async (event, webserviceProps) => { + // New settings requested with an existing instance : + // Destroy the instance if new settings are requested + if (webserviceProps) { + pipeline2instance.updateWebservice(webserviceProps) + } + pipeline2instance.launch() + }) + + // Stop the pipeline instance + ipcMain.on(IPC.PIPELINE.STOP, (event) => pipeline2instance.stop()) + + // get state from the instance + ipcMain.handle(IPC.PIPELINE.STATE.GET, (event) => { + return pipeline2instance.state || null + }) + + // get properties of the instance + ipcMain.handle(IPC.PIPELINE.PROPS.GET, (event) => { + return pipeline2instance.props || null + }) + + // get messages from the instance + ipcMain.handle(IPC.PIPELINE.MESSAGES.GET, (event) => { + return pipeline2instance.messages || null + }) + // get errors from the instance + ipcMain.handle(IPC.PIPELINE.ERRORS.GET, (event) => { + return pipeline2instance.errors || null + }) + + // Launch the pipeline if requested in the settings + if (!settings || (settings && settings.runLocalPipeline)) { + pipeline2instance.launch() + } + + return pipeline2instance +} diff --git a/ui/src/main/factories/ipcs/register-window-creation.ts b/ui/src/main/factories/ipcs/register-window-creation.ts new file mode 100644 index 0000000000..255d004b59 --- /dev/null +++ b/ui/src/main/factories/ipcs/register-window-creation.ts @@ -0,0 +1,17 @@ +import { ipcMain } from 'electron' + +import { WindowCreationByIPC, BrowserWindowOrNull } from 'shared/types' + +export function registerWindowCreationByIPC({ + channel, + callback, + window: createWindow, +}: WindowCreationByIPC) { + let window: BrowserWindowOrNull + ipcMain.on(channel, (event) => { + if (!createWindow || window) return + window = createWindow() + window.on('closed', () => (window = null)) + callback && callback(window, event) + }) +} diff --git a/ui/src/main/factories/ipcs/settings.ts b/ui/src/main/factories/ipcs/settings.ts new file mode 100644 index 0000000000..c6ce9f0e43 --- /dev/null +++ b/ui/src/main/factories/ipcs/settings.ts @@ -0,0 +1,70 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import { ApplicationSettings } from 'shared/types' +import { resolve } from 'path' +import { existsSync, readFileSync, writeFile } from 'fs' +import { resolveUnpacked } from 'shared/utils' +import { IPC } from 'shared/constants' +import { pathToFileURL } from 'url' +import { info } from 'electron-log' + +export function registerApplicationSettingsIPC(): ApplicationSettings { + // Load settings file from current folder + const settingsFile = resolve(app.getPath('userData'), 'settings.json') + let settings: ApplicationSettings = { + // Default folder to download the results on the user disk + downloadFolder: pathToFileURL( + resolve(app.getPath('home'), 'Documents', 'DAISY Pipeline results') + ).href, + // Local pipeline server + // - Run or not a local pipeline server + runLocalPipeline: true, + // - Local pipeline settings + localPipelineProps: { + localPipelineHome: resolveUnpacked('resources', 'daisy-pipeline'), + jrePath: resolveUnpacked('resources', 'daisy-pipeline', 'jre'), + // Note : [49152 ; 65535] is the range of dynamic port, 0 is reserved for error case + webservice: { + // Note : localhost resolve as ipv6 ':::' in nodejs, but we need ipv4 for the pipeline + host: '127.0.0.1', + port: 0, + path: '/ws', + }, + appDataFolder: app.getPath('userData'), + logsFolder: resolve(app.getPath('userData'), 'pipeline-logs'), + }, + // Remote pipeline settings + // - Use a remote pipeline instead of the local one + useRemotePipeline: false, + // - Remote pipeline connection settings to be defined + /*remotePipelineWebservice: { + + }*/ + } + // try to load settings file + try { + if (existsSync(settingsFile)) { + settings = JSON.parse(readFileSync(settingsFile, 'utf8')) + } + } catch (e) { + info('Error when trying to parse settings file') + info(e) + info('Falling back to default settings') + } + // get state from the instance + ipcMain.handle(IPC.WINDOWS.SETTINGS.GET, (event) => { + return settings + }) + + ipcMain.on(IPC.WINDOWS.SETTINGS.UPDATE, (event, newSettings) => { + // Save settings on disk + writeFile(settingsFile, JSON.stringify(newSettings, null, 4), () => { + settings = newSettings + // Send back settings update to all windows + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send(IPC.WINDOWS.SETTINGS.CHANGED, settings) + }) + }) + }) + + return settings +} diff --git a/ui/src/main/factories/ipcs/utils.ts b/ui/src/main/factories/ipcs/utils.ts new file mode 100644 index 0000000000..e4d8e0f29e --- /dev/null +++ b/ui/src/main/factories/ipcs/utils.ts @@ -0,0 +1,98 @@ +import { info } from 'electron-log' +import { readdirSync, statSync } from 'fs' +import { resolve } from 'path' +import { createServer } from 'node:net' +import { setTimeout } from 'timers/promises' + +/** + * + */ +export class Pipeline2Error extends Error { + name: string + + constructor(name: string, message?: string, options?: ErrorOptions) { + super(message, options) + this.name = name + } +} + +/** + * recursive listing of files in a folder with filtering + * @param {string} dir the directory to list files from + * @param {function(string)} filter filter callback, that should return true if a file is matching it + * @returns {string[]} the list of file path matching the filter + */ +export function walk( + dir: string, + filter?: (name: string) => boolean +): string[] { + let results: string[] = [] + let list = readdirSync(dir) + list.forEach(function (file) { + file = resolve(dir, file) + let stat = statSync(file) + if (stat && stat.isDirectory()) { + /* Recurse into a subdirectory */ + results = results.concat(walk(file)) + } else { + /* Is a file */ + ;(!filter || filter(file)) && results.push(file) + } + }) + return results +} + +// Dev notes : +// - port seeking in nodejs default to ipv6 ':::' for unset or localhost hostname +// - ipv4 and 6 do not share ports (based on some tests, one app could listen to an ipv4 port while another listen to the same on the ipv6 side) +// +// Some comments on SOF say that hostname resolution is OS dependent, but some clues on github issues for nodejs +// states that the resolution for 'localhost' defaults to ipv6, starting a version of nodejs i can't remember the number + +/** + * seek for an opened port, or return null if none is available + * + * @param startPort + * @param endPort + * @param host optional hostname or ip adress (default to 127.0.0.1) + */ +export async function getAvailablePort( + startPort: number, + endPort: number, + host: string = '127.0.0.1' +) { + let server = createServer() + let portChecked = startPort + let portOpened = 0 + + // Port seeking : if port is in use, retry with a different port + server.on('error', (err: NodeJS.ErrnoException) => { + info(`Port ${portChecked.toString()} is not usable : `) + info(err) + portChecked += 1 + if (portChecked <= endPort) { + info(' -> Checking for ' + portChecked.toString()) + server.listen(portChecked, host) + } else { + throw new Pipeline2Error( + 'NO_PORT', + 'No port available to host the pipeline webservice' + ) + } + }) + // Listening successfully on a port + server.on('listening', (event) => { + // close the server if listening a port succesfully + server.close(() => { + // select the port when the server is closed + portOpened = portChecked + }) + info(portChecked.toString() + ' is available') + }) + // Start the port seeking + server.listen(portChecked, host) + while (portOpened == 0 && portChecked <= endPort) { + await setTimeout(1000) + } + return portOpened +} diff --git a/ui/src/main/factories/windows/create.ts b/ui/src/main/factories/windows/create.ts new file mode 100644 index 0000000000..003e078e52 --- /dev/null +++ b/ui/src/main/factories/windows/create.ts @@ -0,0 +1,82 @@ +import { app, BrowserWindow, Event, ipcMain } from 'electron' + +import { ENVIRONMENT, IPC, PLATFORM } from 'shared/constants' +import { WindowProps } from 'shared/types' +import { APP_CONFIG } from '~/app.config' +import { Pipeline2IPC } from '../ipcs/pipeline2' + +/** + * Bind a window to a pipeline instance. + * This binding require that the pipeline is already registered in IPC. + * @param binding the window to bind the pipeline with + * @param pipeline the pipeline instance to use + * @param onCloseEventCallback the windows closing callback (if the on close event is not cumulated, might be useless) + */ +export function bindWindowToPipeline( + binding: BrowserWindow, + pipeline: Pipeline2IPC +) { + // Keep the window id here as it is removed before the close event + const windowID = binding.id + pipeline.registerStateListener(`${windowID}`, (state) => { + binding.webContents.send(IPC.PIPELINE.STATE.CHANGED, state) + }) + + pipeline.registerMessagesListener(`${windowID}`, (message) => { + binding.webContents.send(IPC.PIPELINE.MESSAGES.UPDATE, message) + }) + + pipeline.registerErrorsListener(`${windowID}`, (message) => { + binding.webContents.send(IPC.PIPELINE.ERRORS.UPDATE, message) + }) + + ipcMain.on(IPC.PIPELINE.STATE.SEND, (event) => { + binding.webContents.send(IPC.PIPELINE.STATE.CHANGED, pipeline.state) + }) + + binding.on('close', (event) => { + // Remove listeners on closing + pipeline.removeStateListener(`${windowID}`) + pipeline.removeMessageListener(`${windowID}`) + pipeline.removeErrorsListener(`${windowID}`) + }) +} + +export function createWindow({ id, ...settings }: WindowProps) { + const window = new BrowserWindow(settings) + const devServerURL = `${APP_CONFIG.RENDERER.DEV_SERVER.URL}#/${id}` + + ENVIRONMENT.IS_DEV + ? window.loadURL(devServerURL) + : window.loadFile('index.html', { + hash: `/${id}`, + }) + + window.on('closed', window.destroy) + + // bypass CORS + window.webContents.session.webRequest.onBeforeSendHeaders( + (details, callback) => { + callback({ + requestHeaders: { Origin: '*', ...details.requestHeaders }, + }) + } + ) + + window.webContents.session.webRequest.onHeadersReceived( + (details, callback) => { + callback({ + responseHeaders: { + 'Access-Control-Allow-Origin': ['*'], + ...details.responseHeaders, + }, + }) + } + ) + + if (PLATFORM.IS_MAC) { + app.dock.show() + } + + return window +} diff --git a/ui/src/main/fileDialogs.ts b/ui/src/main/fileDialogs.ts new file mode 100644 index 0000000000..bd507ec445 --- /dev/null +++ b/ui/src/main/fileDialogs.ts @@ -0,0 +1,87 @@ +import { ipcMain, dialog, BrowserWindow, shell } from 'electron' +import { pathToFileURL } from 'node:url' + +// helper functions +import { + IPC_EVENT_showItemInFolder, + IPC_EVENT_showOpenFileDialog, + IPC_EVENT_showSaveDialog, +} from '../shared/main-renderer-events' + +/* +dialogOptions: https://www.electronjs.org/docs/latest/api/dialog +{ + title: the title of the dialog + buttonLabel: 'Open', 'Select', 'Whatever' + properties: [] 'openFile', 'openDirectory', 'createDirectory' + filters: [ {label, rule}, ... ] +} +*/ + +const showOpenFileDialog = async ( + callback: (filepath: string) => void, + dialogOptions: Electron.OpenDialogOptions, + asFileURL: boolean = true +): Promise => { + let filePath + const res = await dialog.showOpenDialog( + BrowserWindow ? BrowserWindow.getFocusedWindow() : undefined, + dialogOptions + ) + if (res.canceled || !res.filePaths || !res.filePaths.length) { + filePath = undefined + } + if (res.filePaths[0]) { + filePath = asFileURL + ? pathToFileURL(res.filePaths[0]).href + : res.filePaths[0] + } else { + filePath = undefined + } + if (callback && filePath) { + callback(filePath) + } +} + +const showSaveDialog = async ( + callback: (filepath: string) => void, + dialogOptions: Electron.SaveDialogOptions, + asFileURL: boolean = true +): Promise => { + let filePath + // @ts-ignore + const res = await dialog.showSaveDialog( + BrowserWindow ? BrowserWindow.getFocusedWindow() : undefined, + dialogOptions + ) + if (res.canceled || !res.filePath) { + filePath = undefined + } + if (res.filePath) { + filePath = asFileURL ? pathToFileURL(res.filePath).href : res.filePath + } else { + filePath = undefined + } + if (callback && filePath) { + callback(filePath) + } +} + +function setupFileDialogEvents() { + // comes from the renderer process (ipcRenderer.send()) + ipcMain.on(IPC_EVENT_showOpenFileDialog, async (event, payload) => { + await showOpenFileDialog( + (filePath) => { + event.sender.send(IPC_EVENT_showOpenFileDialog, filePath) + }, + payload.dialogOptions, + payload.getFileURL + ) + }) + ipcMain.on(IPC_EVENT_showSaveDialog, async (event, payload) => { + await showSaveDialog((filePath) => { + event.sender.send(IPC_EVENT_showSaveDialog, filePath) + }, payload.getFileURL) + }) +} +export { setupFileDialogEvents, showOpenFileDialog, showSaveDialog } diff --git a/ui/src/main/fileSystem.ts b/ui/src/main/fileSystem.ts new file mode 100644 index 0000000000..95cb90516c --- /dev/null +++ b/ui/src/main/fileSystem.ts @@ -0,0 +1,26 @@ +// various file system utility functions + +import { ipcMain } from 'electron' +import fs from 'fs-extra' + +// helper functions +const { IPC_EVENT_pathExists } = require('../shared/main-renderer-events') + +async function pathExists(path) { + await fs.access(path, (err) => { + if (err) { + return false + } else { + return true + } + }) +} + +function setupFileSystemEvents() { + // comes from the renderer process (ipcRenderer.send()) + ipcMain.on(IPC_EVENT_pathExists, async (event, payload) => { + let res = await pathExists(payload) + event.sender.send(IPC_EVENT_pathExists, res) + }) +} +export { setupFileSystemEvents, pathExists } diff --git a/ui/src/main/folder.ts b/ui/src/main/folder.ts new file mode 100644 index 0000000000..a362db6c67 --- /dev/null +++ b/ui/src/main/folder.ts @@ -0,0 +1,22 @@ +import { ipcMain, shell } from 'electron' +import { PLATFORM } from 'shared/constants' + +// helper functions +import { IPC_EVENT_showItemInFolder } from '../shared/main-renderer-events' + +function setupShowInFolderEvents() { + // payload should be "/Path/to/folder" not file://.. + ipcMain.on(IPC_EVENT_showItemInFolder, (event, payload) => { + let f = payload + if (PLATFORM.IS_WINDOWS) { + if (f[0] == '/') { + f = f.slice(1) + f = f.replaceAll('/', '\\') + } + } + if (f.endsWith('/') || f.endsWith('\\')) { + shell.openPath(f) + } else shell.showItemInFolder(f) + }) +} +export { setupShowInFolderEvents } diff --git a/ui/src/main/index.ts b/ui/src/main/index.ts new file mode 100644 index 0000000000..6c88f6025d --- /dev/null +++ b/ui/src/main/index.ts @@ -0,0 +1,150 @@ +import { + app, + BrowserWindow, + ipcMain, + Menu, + MenuItemConstructorOptions, + shell, +} from 'electron' + +import { error } from 'electron-log' + +import { + bindWindowToPipeline, + makeAppSetup, + makeAppWithSingleInstanceLock, + Pipeline2IPC, + registerApplicationSettingsIPC, + registerPipeline2ToIPC, +} from './factories' + +import { + MainWindow, + PipelineTray, + registerAboutWindowCreationByIPC, + registerSettingsWindowCreationByIPC, +} from './windows' + +import { setupFileDialogEvents } from './fileDialogs' +import { ENVIRONMENT, IPC } from 'shared/constants' +import { setupShowInFolderEvents } from './folder' +import { registerFileIPC } from './factories/ipcs/file' +import { setupFileSystemEvents } from './fileSystem' +import { setupOpenInBrowserEvents } from './browser' +import { APP_CONFIG } from '~/app.config' + +makeAppWithSingleInstanceLock(async () => { + await app.whenReady() + // Windows + let mainWindow = await makeAppSetup(MainWindow) + registerSettingsWindowCreationByIPC() + registerAboutWindowCreationByIPC() + registerFileIPC() + // Settings + let settings = registerApplicationSettingsIPC() + + // Pipeline instance creation with IPC communication registering + const pipelineInstance = registerPipeline2ToIPC(settings) + bindWindowToPipeline(mainWindow, pipelineInstance) + + let tray: PipelineTray = null + try { + tray = new PipelineTray(mainWindow, pipelineInstance) + } catch (err) { + error(err) + // quit app for now but we might need to think for a better handling for the user + app.quit() + } + setupFileDialogEvents() + setupShowInFolderEvents() + setupOpenInBrowserEvents() + setupFileSystemEvents() + + const isMac = process.platform === 'darwin' + + // Template taken from electron documentation + // To be completed + // @ts-ignore + const template: MenuItemConstructorOptions = [ + // { role: 'appMenu' } + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ] + : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + { + label: 'Create a new job', + click: async () => { + try { + mainWindow.show() + } catch (error) { + mainWindow = await MainWindow() + bindWindowToPipeline(mainWindow, pipelineInstance) + } + }, + }, + { + label: 'Settings', + click: async () => { + // Open the settings window + ipcMain.emit(IPC.WINDOWS.SETTINGS.CREATE) + }, + }, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [{ role: 'copy' }, { role: 'paste' }], + }, + { + label: 'View', + submenu: [ + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + ], + }, + { + role: 'help', + submenu: [ + { + label: 'Learn more', + click: async () => { + await shell.openExternal( + 'https://daisy.github.io/pipeline/' + ) + }, + }, + { + label: 'User guide', + click: async () => { + await shell.openExternal( + 'https://daisy.github.io/pipeline/Get-Help/' + ) + }, + }, + ], + }, + ] + + // @ts-ignore + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +}) diff --git a/ui/src/main/windows/About/index.ts b/ui/src/main/windows/About/index.ts new file mode 100644 index 0000000000..4d912a2889 --- /dev/null +++ b/ui/src/main/windows/About/index.ts @@ -0,0 +1,26 @@ +import { createWindow } from 'main/factories' +import { join } from 'path' +import { APP_CONFIG } from '~/app.config' + +export * from './ipcs' + +export function AboutWindow() { + const window = createWindow({ + id: 'about', + title: `${APP_CONFIG.TITLE} - About`, + width: 450, + height: 350, + resizable: false, + alwaysOnTop: true, + + webPreferences: { + preload: join(__dirname, 'bridge.js'), + nodeIntegration: false, + contextIsolation: true, + spellcheck: false, + sandbox: false, + }, + }) + + return window +} diff --git a/ui/src/main/windows/About/ipcs/index.ts b/ui/src/main/windows/About/ipcs/index.ts new file mode 100644 index 0000000000..05d7e02910 --- /dev/null +++ b/ui/src/main/windows/About/ipcs/index.ts @@ -0,0 +1 @@ +export * from './register-window-creation' diff --git a/ui/src/main/windows/About/ipcs/register-window-creation.ts b/ui/src/main/windows/About/ipcs/register-window-creation.ts new file mode 100644 index 0000000000..6ca0f694cd --- /dev/null +++ b/ui/src/main/windows/About/ipcs/register-window-creation.ts @@ -0,0 +1,26 @@ +import { ipcMain } from 'electron' + +import { registerWindowCreationByIPC } from 'main/factories' +import { IPC } from 'shared/constants' +import { AboutWindow } from '..' + +export function registerAboutWindowCreationByIPC() { + registerWindowCreationByIPC({ + channel: IPC.WINDOWS.ABOUT.CREATE, + window: AboutWindow, + + callback(window, event) { + const channel = IPC.WINDOWS.ABOUT.WHEN_CLOSE + ipcMain.removeHandler(channel) + window.on( + 'closed', + () => + event && + event.sender && + event.sender.send(channel, { + message: 'About window closed!', + }) + ) + }, + }) +} diff --git a/ui/src/main/windows/Main/index.ts b/ui/src/main/windows/Main/index.ts new file mode 100644 index 0000000000..7706df0e4d --- /dev/null +++ b/ui/src/main/windows/Main/index.ts @@ -0,0 +1,37 @@ +import { app, BrowserWindow } from 'electron' +import { join } from 'path' + +import { ENVIRONMENT, PLATFORM } from 'shared/constants' +import { createWindow, Pipeline2IPC } from 'main/factories' +import { APP_CONFIG } from '~/app.config' + +const { MAIN, TITLE } = APP_CONFIG + +export async function MainWindow() { + const window = createWindow({ + id: 'main', + title: TITLE, + width: MAIN.WINDOW.WIDTH, + height: MAIN.WINDOW.HEIGHT, + center: true, + movable: true, + resizable: true, + alwaysOnTop: false, + + webPreferences: { + preload: join(__dirname, 'bridge.js'), + nodeIntegration: false, + contextIsolation: true, + spellcheck: false, + sandbox: false, + }, + }) + + ENVIRONMENT.IS_DEV && window.webContents.openDevTools({ mode: 'detach' }) + + window.on('close', (event) => { + BrowserWindow.getAllWindows().forEach((window) => window.destroy()) + }) + + return window +} diff --git a/ui/src/main/windows/Settings/index.ts b/ui/src/main/windows/Settings/index.ts new file mode 100644 index 0000000000..89af34af99 --- /dev/null +++ b/ui/src/main/windows/Settings/index.ts @@ -0,0 +1,29 @@ +import { createWindow } from 'main/factories' +import { join } from 'path' +import { ENVIRONMENT } from 'shared/constants' +import { APP_CONFIG } from '~/app.config' + +export * from './ipcs' + +export function SettingsWindow() { + const window = createWindow({ + id: 'settings', + title: `${APP_CONFIG.TITLE} - Settings`, + width: 800, + height: 450, + resizable: true, + alwaysOnTop: true, + + webPreferences: { + preload: join(__dirname, 'bridge.js'), + nodeIntegration: false, + contextIsolation: true, + spellcheck: false, + sandbox: false, + }, + }) + + ENVIRONMENT.IS_DEV && window.webContents.openDevTools({ mode: 'detach' }) + + return window +} diff --git a/ui/src/main/windows/Settings/ipcs/index.ts b/ui/src/main/windows/Settings/ipcs/index.ts new file mode 100644 index 0000000000..05d7e02910 --- /dev/null +++ b/ui/src/main/windows/Settings/ipcs/index.ts @@ -0,0 +1 @@ +export * from './register-window-creation' diff --git a/ui/src/main/windows/Settings/ipcs/register-window-creation.ts b/ui/src/main/windows/Settings/ipcs/register-window-creation.ts new file mode 100644 index 0000000000..375ab56d40 --- /dev/null +++ b/ui/src/main/windows/Settings/ipcs/register-window-creation.ts @@ -0,0 +1,28 @@ +import { ipcMain } from 'electron' + +import { registerWindowCreationByIPC } from 'main/factories' +import { IPC } from 'shared/constants' +import { SettingsWindow } from '..' + +export function registerSettingsWindowCreationByIPC() { + registerWindowCreationByIPC({ + channel: IPC.WINDOWS.SETTINGS.CREATE, + window: SettingsWindow, + + callback(window, event) { + const channel = IPC.WINDOWS.SETTINGS.WHEN_CLOSE + + ipcMain.removeHandler(channel) + + window.on( + 'closed', + () => + event && + event.sender && + event.sender.send(channel, { + message: 'Settings window closed!', + }) + ) + }, + }) +} diff --git a/ui/src/main/windows/Tray/index.ts b/ui/src/main/windows/Tray/index.ts new file mode 100644 index 0000000000..e0bdb47287 --- /dev/null +++ b/ui/src/main/windows/Tray/index.ts @@ -0,0 +1,147 @@ +import { + app, + Menu, + Tray, + BrowserWindow, + ipcMain, + ipcRenderer, + nativeImage, +} from 'electron' +import { APP_CONFIG } from '~/app.config' +import { resolve } from 'path' +import { + bindWindowToPipeline, + makeAppSetup, + makeAppWithSingleInstanceLock, + Pipeline2IPC, +} from '../../factories' +import { MainWindow, AboutWindow } from '../../windows' +import { ENVIRONMENT, IPC } from 'shared/constants' +import { PipelineState, PipelineStatus } from 'shared/types' +import { resolveUnpacked } from 'shared/utils' + +export class PipelineTray { + tray: Tray + mainWindow: BrowserWindow + pipeline?: Pipeline2IPC = null + menuBaseTemplate: Array = [] + pipelineMenu: Array = [] + + constructor(mainWindow: BrowserWindow, pipeline?: Pipeline2IPC) { + const icon = nativeImage.createFromPath( + resolveUnpacked('resources', 'icons', 'logo_32x32.png') + ) + this.tray = new Tray(icon) + this.mainWindow = mainWindow + this.menuBaseTemplate = [ + // Note : uncomment if we want those window + // { + // label: 'About', + // click: async (item, window, event) => { + // ipcMain.emit(IPC.WINDOWS.ABOUT.CREATE) + // }, + // }, + { + label: 'Settings', + click: async (item, window, event) => { + ipcMain.emit(IPC.WINDOWS.SETTINGS.CREATE) + }, + }, + { + label: 'Quit', + click: (item, window, event) => { + BrowserWindow.getAllWindows().forEach((window) => + window.destroy() + ) + pipeline && pipeline.stop(true) + app.quit() + }, + }, + ] + + if (pipeline) { + this.bindToPipeline(pipeline) + } else { + this.pipelineMenu = [ + { + label: 'Pipeline could not be launched', + enabled: false, + }, + ] + this.tray.setToolTip('DAISY Pipeline 2') + this.tray.setContextMenu( + Menu.buildFromTemplate([ + ...this.pipelineMenu, + ...this.menuBaseTemplate, + ]) + ) + } + } + + /** + * Bind a pipeline instance to the tray to allow interactions + * @param pipeline + */ + bindToPipeline(pipeline: Pipeline2IPC) { + // setup listeners to update tray based on states + pipeline.registerStateListener('tray', (newState) => { + this.updateElectronTray(newState, pipeline) + }) + this.updateElectronTray(pipeline.state, pipeline) + } + + /** + * Update the tray based on a given pipeline state + * @param newState State to use for tray configuration + * @param pipeline pipeline instance to be controled by the tray actions + */ + updateElectronTray(newState: PipelineState, pipeline: Pipeline2IPC) { + this.pipelineMenu = [ + { + label: `Pipeline is ${ + newState.status == PipelineStatus.STARTING || + newState.status == PipelineStatus.RUNNING + ? 'running' + : 'stopped' + }`, + enabled: false, + }, + { + label: 'Create a job', + enabled: + newState.status == PipelineStatus.STARTING || + newState.status == PipelineStatus.RUNNING, + click: async (item, window, event) => { + try { + this.mainWindow.show() + } catch (error) { + this.mainWindow = await MainWindow() + bindWindowToPipeline(this.mainWindow, pipeline) + } + // Note : this triggers a refresh + // ENVIRONMENT.IS_DEV + // ? this.mainWindow.loadURL( + // `${APP_CONFIG.RENDERER.DEV_SERVER.URL}#/main` + // ) + // : this.mainWindow.loadFile('index.html', { + // hash: `/main`, + // }) + }, + }, + ] + this.tray.setToolTip( + 'DAISY Pipeline 2 is ' + + (newState.status == PipelineStatus.STARTING || + newState.status == PipelineStatus.RUNNING + ? 'running' + : 'stopped') + ) + // Update tray + this.tray.setContextMenu( + Menu.buildFromTemplate([ + ...this.pipelineMenu, + ...this.menuBaseTemplate, + ]) + ) + } +} diff --git a/ui/src/main/windows/index.ts b/ui/src/main/windows/index.ts new file mode 100644 index 0000000000..2803674639 --- /dev/null +++ b/ui/src/main/windows/index.ts @@ -0,0 +1,4 @@ +export * from './About' +export * from './Settings' +export * from './Main' +export * from './Tray' diff --git a/ui/src/renderer/assets/images/illustration.svg b/ui/src/renderer/assets/images/illustration.svg new file mode 100644 index 0000000000..0820cf9420 --- /dev/null +++ b/ui/src/renderer/assets/images/illustration.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/renderer/bridge/index.ts b/ui/src/renderer/bridge/index.ts new file mode 100644 index 0000000000..32a85dd4fc --- /dev/null +++ b/ui/src/renderer/bridge/index.ts @@ -0,0 +1,22 @@ +import { contextBridge } from 'electron' +import * as ipcs from './ipcs' + +declare global { + interface Window { + App: typeof API + } +} + +const API = { + ...ipcs, + sayHelloFromBridge: () => console.log('\nHello from bridgeAPI! 👋\n\n'), + username: process.env.USER, + showOpenFileDialog: ipcs.showOpenFileDialog, + showSaveDialog: ipcs.showSaveDialog, + showItemInFolder: ipcs.showItemInFolder, + openInBrowser: ipcs.openInBrowser, + pathExists: ipcs.pathExists, + whenAboutWindowClosed: ipcs.whenAboutWindowClose, +} + +contextBridge.exposeInMainWorld('App', API) diff --git a/ui/src/renderer/bridge/ipcs/about-window/create.ts b/ui/src/renderer/bridge/ipcs/about-window/create.ts new file mode 100644 index 0000000000..3f0e90151d --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/about-window/create.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function createAboutWindow() { + const channel = IPC.WINDOWS.ABOUT.CREATE + + ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/about-window/when-close.ts b/ui/src/renderer/bridge/ipcs/about-window/when-close.ts new file mode 100644 index 0000000000..2a0a94f490 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/about-window/when-close.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function whenAboutWindowClose(fn: (...args: any[]) => void) { + const channel = IPC.WINDOWS.ABOUT.WHEN_CLOSE + + ipcRenderer.on(channel, (_, ...args) => { + fn(...args) + }) +} diff --git a/ui/src/renderer/bridge/ipcs/browser.ts b/ui/src/renderer/bridge/ipcs/browser.ts new file mode 100644 index 0000000000..c78ab0b6a2 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/browser.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import * as events from 'shared/main-renderer-events' + +export function openInBrowser(payload) { + ipcRenderer.send(events.IPC_EVENT_openInBrowser, payload) +} diff --git a/ui/src/renderer/bridge/ipcs/clipboard.ts b/ui/src/renderer/bridge/ipcs/clipboard.ts new file mode 100644 index 0000000000..8a238a16fa --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/clipboard.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import * as events from 'shared/main-renderer-events' + +export function copyToClipboard(payload) { + return new Promise((resolve, reject) => { + ipcRenderer.send(events.IPC_EVENT_copyToClipboard, payload) + }) +} diff --git a/ui/src/renderer/bridge/ipcs/file/save.ts b/ui/src/renderer/bridge/ipcs/file/save.ts new file mode 100644 index 0000000000..a79cba3d59 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/file/save.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function saveFile(buffer: ArrayBuffer, pathFileURL: string) { + const channel = IPC.FILE.SAVE + return ipcRenderer.invoke(channel, buffer, pathFileURL) +} diff --git a/ui/src/renderer/bridge/ipcs/file/unzip.ts b/ui/src/renderer/bridge/ipcs/file/unzip.ts new file mode 100644 index 0000000000..69d36dd04b --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/file/unzip.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function unzipFile(buffer: ArrayBuffer, pathFileURL: string) { + const channel = IPC.FILE.UNZIP + return ipcRenderer.invoke(channel, buffer, pathFileURL) +} diff --git a/ui/src/renderer/bridge/ipcs/fileDialog.ts b/ui/src/renderer/bridge/ipcs/fileDialog.ts new file mode 100644 index 0000000000..691e11754a --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/fileDialog.ts @@ -0,0 +1,37 @@ +import { ipcRenderer } from 'electron' +import * as events from 'shared/main-renderer-events' + +// options: { title, buttonLabel, properties, filters } +export function showOpenFileDialog(options: { + dialogOptions: Electron.OpenDialogOptions + asFileURL?: boolean +}) { + return new Promise((resolve, reject) => { + // TODO look at item.mediaType to see if it's an anyFileURI or anyDirURI + // also I think windows and mac do file vs folder browsing a little differently + ipcRenderer.send(events.IPC_EVENT_showOpenFileDialog, options) + ipcRenderer.once( + events.IPC_EVENT_showOpenFileDialog, + (event, filepath: string) => { + resolve(filepath) + } + ) + }) +} + +export function showSaveDialog(options: { + dialogOptions: Electron.SaveDialogOptions + asFileURL?: boolean +}) { + return new Promise((resolve, reject) => { + // TODO look at item.mediaType to see if it's an anyFileURI or anyDirURI + // also I think windows and mac do file vs folder browsing a little differently + ipcRenderer.send(events.IPC_EVENT_showSaveDialog, options) + ipcRenderer.once( + events.IPC_EVENT_showSaveDialog, + (event, filepath: string) => { + resolve(filepath) + } + ) + }) +} diff --git a/ui/src/renderer/bridge/ipcs/fileSystem.ts b/ui/src/renderer/bridge/ipcs/fileSystem.ts new file mode 100644 index 0000000000..1b3387c0aa --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/fileSystem.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import * as events from 'shared/main-renderer-events' + +export function pathExists(path) { + return new Promise((resolve, reject) => { + ipcRenderer.send(events.IPC_EVENT_pathExists, path) + ipcRenderer.once(events.IPC_EVENT_pathExists, (event, res: boolean) => { + resolve(res) + }) + }) +} diff --git a/ui/src/renderer/bridge/ipcs/folder.ts b/ui/src/renderer/bridge/ipcs/folder.ts new file mode 100644 index 0000000000..fddd0493a7 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/folder.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import * as events from 'shared/main-renderer-events' + +export function showItemInFolder(payload) { + return new Promise((resolve, reject) => { + ipcRenderer.send(events.IPC_EVENT_showItemInFolder, payload) + }) +} diff --git a/ui/src/renderer/bridge/ipcs/index.ts b/ui/src/renderer/bridge/ipcs/index.ts new file mode 100644 index 0000000000..d2963d8385 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/index.ts @@ -0,0 +1,19 @@ +export * from './about-window/when-close' +export * from './about-window/create' +export * from './fileDialog' +export * from './clipboard' +export * from './folder' + +export * from './pipeline2/errors' +export * from './pipeline2/messages' +export * from './pipeline2/start' +export * from './pipeline2/stop' +export * from './pipeline2/state' +export * from './pipeline2/props' + +export * from './settings' +export * from './fileSystem' +export * from './browser' + +export * from './file/save' +export * from './file/unzip' diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/errors.ts b/ui/src/renderer/bridge/ipcs/pipeline2/errors.ts new file mode 100644 index 0000000000..641478318c --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/errors.ts @@ -0,0 +1,15 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function onPipelineError(callback) { + const channel = IPC.PIPELINE.ERRORS.UPDATE + + return ipcRenderer.on(channel, callback) +} + +export function getPipelineErrors(): Promise | null> { + const channel = IPC.PIPELINE.ERRORS.GET + + return ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/messages.ts b/ui/src/renderer/bridge/ipcs/pipeline2/messages.ts new file mode 100644 index 0000000000..70256d95ba --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/messages.ts @@ -0,0 +1,15 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function onPipelineMessage(callback) { + const channel = IPC.PIPELINE.MESSAGES.UPDATE + + return ipcRenderer.on(channel, callback) +} + +export function getPipelineMessages(): Promise | null> { + const channel = IPC.PIPELINE.MESSAGES.GET + + return ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/props.ts b/ui/src/renderer/bridge/ipcs/pipeline2/props.ts new file mode 100644 index 0000000000..24241d0b21 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/props.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' +import { Pipeline2IPCProps } from 'main/factories' + +import { IPC } from 'shared/constants' + +export function getPipelineProps(): Promise { + const channel = IPC.PIPELINE.PROPS.GET + return ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/start.ts b/ui/src/renderer/bridge/ipcs/pipeline2/start.ts new file mode 100644 index 0000000000..40cffcf63f --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/start.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import { Pipeline2IPCProps } from 'main/factories' + +import { IPC } from 'shared/constants' +import { PipelineState, Webservice } from 'shared/types' + +export function launchPipeline( + webserviceProps?: Webservice +): Promise { + const channel = IPC.PIPELINE.START + return ipcRenderer.invoke(channel, webserviceProps) +} diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/state.ts b/ui/src/renderer/bridge/ipcs/pipeline2/state.ts new file mode 100644 index 0000000000..4884724593 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/state.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' +import { PipelineState } from 'shared/types' + +export function onPipelineStateChanged(callback) { + const channel = IPC.PIPELINE.STATE.CHANGED + + return ipcRenderer.on(channel, callback) +} + +export function requestPipelineState() { + const channel = IPC.PIPELINE.STATE.SEND + ipcRenderer.send(channel) +} + +export function getPipelineState(): Promise { + const channel = IPC.PIPELINE.STATE.GET + return ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/pipeline2/stop.ts b/ui/src/renderer/bridge/ipcs/pipeline2/stop.ts new file mode 100644 index 0000000000..ef55c583d3 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/pipeline2/stop.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function stopPipeline() { + const channel = IPC.PIPELINE.STOP + + ipcRenderer.send(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/settings/changed.ts b/ui/src/renderer/bridge/ipcs/settings/changed.ts new file mode 100644 index 0000000000..480545b6cc --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/changed.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +/** + * Update the settings of the application on the backend + * @returns a promise containing the settings, or null if no settings were saved before + */ +export function onSettingsChanged(callback) { + const channel = IPC.WINDOWS.SETTINGS.CHANGED + return ipcRenderer.on(channel, callback) +} diff --git a/ui/src/renderer/bridge/ipcs/settings/create-window.ts b/ui/src/renderer/bridge/ipcs/settings/create-window.ts new file mode 100644 index 0000000000..0a3dc45a47 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/create-window.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function createSettingsWindow() { + const channel = IPC.WINDOWS.SETTINGS.CREATE + + ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/settings/get.ts b/ui/src/renderer/bridge/ipcs/settings/get.ts new file mode 100644 index 0000000000..68b731f79b --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/get.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' +import { ApplicationSettings } from 'shared/types' + +/** + * Retrieve the settings of the application + * @returns a promise containing the settings, or null if no settings were saved before + */ +export function getSettings(): Promise { + const channel = IPC.WINDOWS.SETTINGS.GET + return ipcRenderer.invoke(channel) +} diff --git a/ui/src/renderer/bridge/ipcs/settings/index.ts b/ui/src/renderer/bridge/ipcs/settings/index.ts new file mode 100644 index 0000000000..60fc6372ce --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/index.ts @@ -0,0 +1,5 @@ +export * from './create-window' +export * from './get' +export * from './changed' +export * from './update' +export * from './when-window-close' diff --git a/ui/src/renderer/bridge/ipcs/settings/update.ts b/ui/src/renderer/bridge/ipcs/settings/update.ts new file mode 100644 index 0000000000..5cda76ec53 --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/update.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' +import { ApplicationSettings } from 'shared/types' + +/** + * Send the new application settings to the backend + */ +export function saveSettings(newSettings: ApplicationSettings) { + const channel = IPC.WINDOWS.SETTINGS.UPDATE + ipcRenderer.send(channel, newSettings) +} diff --git a/ui/src/renderer/bridge/ipcs/settings/when-window-close.ts b/ui/src/renderer/bridge/ipcs/settings/when-window-close.ts new file mode 100644 index 0000000000..0337561b5c --- /dev/null +++ b/ui/src/renderer/bridge/ipcs/settings/when-window-close.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +import { IPC } from 'shared/constants' + +export function whenSettingsWindowClose(fn: (...args: any[]) => void) { + const channel = IPC.WINDOWS.SETTINGS.WHEN_CLOSE + + ipcRenderer.on(channel, (_, ...args) => { + fn(...args) + }) +} diff --git a/ui/src/renderer/components/CustomFields/FileOrFolderInput.tsx b/ui/src/renderer/components/CustomFields/FileOrFolderInput.tsx new file mode 100644 index 0000000000..bf3aabce36 --- /dev/null +++ b/ui/src/renderer/components/CustomFields/FileOrFolderInput.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect } from 'react' +import { mediaTypesFileFilters } from 'shared/constants' + +const { App } = window + +// create a file or folder selector +// we can't use HTML because even with the folder option enabled by "webkitdirectory" +// it won't let users select an empty folder +// and we can't reuse even as a control to trigger electron's native file picker +// because you can't set the value on the input field programmatically (yes we could use loads of react code to work around this but let's not) +// so this function provides a button to browse and a text display of the path +export function FileOrFolderInput({ + dialogProperties, + elemId, + mediaType, + onChange, + name = null, + type = 'open', + buttonLabel = 'Browse', // what the button is called that you click to bring up a file dialog + useSystemPath = false, + required = false, + initialValue = '', +}: { + dialogProperties: string[] // electron dialog properties for open or save, depending on which one you're doing (see 'type') + elemId: string // ID for the control widget + mediaType: string[] // array of mimetypes + onChange: (filename: string) => void // callback function + initialValue?: string // the displayed value + name?: string // display name for the thing we're picking + type: 'open' | 'save' // 'open' or 'save' are a little different in electron + // save supports the 'createDirectory' property for macos + buttonLabel?: string // the label for the control (not the label on the dialog) + useSystemPath?: boolean // i forget what this is for but it defaults to 'true' and everywhere seems to set it to 'false' + required?: boolean +}) { + // the value is stored internally as it can be set 2 ways + // and also broadcast via onChange so that a parent component can subscribe + const [value, setValue] = useState('') + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + let updateFilename = (filename) => { + setValue(filename) + if (onChange) { + onChange(filename) + } + } + + let onClick = async (e, name) => { + e.preventDefault() + let filters = getFiletypeFilters(mediaType) + let filename = '' + let options = { + title: `Select ${name ?? ''}`, + defaultPath: value?.replace('file://', '') ?? '', + buttonLabel: 'Select', // this is a different buttonLabel, it's the one for the actual file browse dialog + filters, + // @ts-ignore + properties: dialogProperties, + } + if (type == 'open') { + filename = await App.showOpenFileDialog({ + //@ts-ignore + dialogOptions: options, + asFileURL: !useSystemPath, + }) + } else if (type == 'save') { + filename = await App.showSaveDialog({ + // @ts-ignore + dialogOptions: options, + asFileURL: !useSystemPath, + }) + } + updateFilename(filename) + } + + let onTextInput = (e) => { + updateFilename(e.target.value) + } + + // all items that make it to this function have type of 'anyFileURI' or 'anyDirURI'` + return ( +
+ + +
+ ) +} + +function getFiletypeFilters(mediaType) { + let filters_ = Array.isArray(mediaType) + ? mediaType + .filter((mt) => mediaTypesFileFilters.hasOwnProperty(mt)) + .map((mt) => mediaTypesFileFilters[mt]) + : [] + + // merge the values in the filters so that instead of + // filters: [{name: 'EPUB', extensions: ['epub']}, {name: 'Package', extensions['opf']}] + // we get + // filters: [{name: "EPUB, Package", extensions: ['epub', 'opf']}] + let filterNames = filters_.map((f) => f.name).join(', ') + let filterExts = filters_.map((f) => f.extensions).flat() + + let filters = [{ name: filterNames, extensions: filterExts }] + + filters.push(mediaTypesFileFilters['*']) + + return filters +} diff --git a/ui/src/renderer/components/JobDetailsPane/Messages.tsx b/ui/src/renderer/components/JobDetailsPane/Messages.tsx new file mode 100644 index 0000000000..b79e70ae05 --- /dev/null +++ b/ui/src/renderer/components/JobDetailsPane/Messages.tsx @@ -0,0 +1,18 @@ +import { MessageLevel } from 'shared/types' + +let messageSort = (a, b) => (a.sequence < b.sequence ? b : a) + +export function Messages({ job }) { + return ( +
    + {job.jobData.messages?.sort(messageSort).map((message, idx) => ( +
  • + {message.timestamp} - {message.level}: {message.content} +
  • + ))} +
+ ) +} diff --git a/ui/src/renderer/components/JobDetailsPane/Results.tsx b/ui/src/renderer/components/JobDetailsPane/Results.tsx new file mode 100644 index 0000000000..7b5b3333d9 --- /dev/null +++ b/ui/src/renderer/components/JobDetailsPane/Results.tsx @@ -0,0 +1,73 @@ +export function Results({ job }) { + let handleWebLink = (e) => { + e.preventDefault() + App.openInBrowser(e.target.href) + } + + return ( +
    + {job.jobData.results?.namedResults.map((item, itemIndex) => ( +
  • + {item.files.length > 1 ? ( + <> + {item.nicename} +
      + {item.files.map((resultFile, resultIndex) => ( +
    • + +
    • + ))} +
    + + ) : ( + + {item.nicename} + + )} +
  • + ))} + {job?.jobData?.log ? ( +
  • + + Log + +
  • + ) : ( + '' + )} +
+ ) +} + +const { App } = window + +interface FileLinkProps { + fileHref: string + children?: React.ReactNode +} + +function FileLink({ fileHref, children }: FileLinkProps) { + let localPath = fileHref + ? decodeURI(fileHref.replace('file:', '').replace('///', '/')) + : '' + let filename = fileHref + ? fileHref.slice( + fileHref.lastIndexOf('/'), + fileHref.length - fileHref.lastIndexOf('/') + ) + : '' + + let onClick = (e) => { + e.preventDefault() + if (localPath) App.showItemInFolder(localPath) + else App.openInBrowser(fileHref) + } + + return ( + + {children ?? filename} + + ) +} diff --git a/ui/src/renderer/components/JobDetailsPane/Settings.tsx b/ui/src/renderer/components/JobDetailsPane/Settings.tsx new file mode 100644 index 0000000000..9972eb402e --- /dev/null +++ b/ui/src/renderer/components/JobDetailsPane/Settings.tsx @@ -0,0 +1,28 @@ +// job settings, not application settings +import { findValue, getAllOptional, getAllRequired } from 'renderer/utils/utils' + +export function Settings({ job }) { + let required = getAllRequired(job.script) + let optional = getAllOptional(job.script) + + return ( +
    + {required.map((item, idx) => ( +
  • + {item.nicename}: + + {findValue(item.name, item.kind, job.jobRequest)} + +
  • + ))} + {optional.map((item, idx) => ( +
  • + {item.nicename}: + + {findValue(item.name, item.kind, job.jobRequest)} + +
  • + ))} +
+ ) +} diff --git a/ui/src/renderer/components/JobDetailsPane/index.tsx b/ui/src/renderer/components/JobDetailsPane/index.tsx new file mode 100644 index 0000000000..63b6569990 --- /dev/null +++ b/ui/src/renderer/components/JobDetailsPane/index.tsx @@ -0,0 +1,108 @@ +/* +Details of a submitted job +*/ +import { JobStatus } from '/shared/types' +import { Messages } from './Messages' +import { Settings } from './Settings' +import { Results } from './Results' +import { Section } from '../Section' + +import { ID } from '../../utils/utils' + +const { App } = window + +const readableStatus = { + IDLE: 'Waiting', + RUNNING: 'Running', + ERROR: 'Error', + SUCCESS: 'Completed', + FAIL: 'Error', +} + +export function JobDetailsPane({ job }) { + return ( + <> +
+
+

+ {job.jobData.nicename} +

+

{job.script.description}

+

+ Job status: {readableStatus[job.jobData.status]} {job.jobData.progress ? `(${job.jobData.progress * 100}%)` : '' } +

+
+ {job.jobData.status == JobStatus.SUCCESS || + job.jobData.status == JobStatus.FAIL ? ( + + ) : ( + '' + )} +
+
+
+ +
+
+ +
+
+ +
+
+ + ) +} + +function JobResults({ jobId, results }) { + // this is a hack! + // get the first file and use its path to figure out what is probably the output folder for the job + let file = '' + if (results?.namedResults.length > 0) { + if (results.namedResults[0].files.length > 0) { + file = results.namedResults[0].files[0].file + let idx = file.indexOf(jobId) + if (idx != -1) { + file = file.slice(0, idx + jobId.length) + '/' + file = file.replace('file:', '').replace('///', '/') + file = decodeURI(file) + } + } + } + + if (file != '') { + return ( + + ) + } else { + return

Results unavailable

+ } +} diff --git a/ui/src/renderer/components/JobTab/index.tsx b/ui/src/renderer/components/JobTab/index.tsx new file mode 100644 index 0000000000..da107c5458 --- /dev/null +++ b/ui/src/renderer/components/JobTab/index.tsx @@ -0,0 +1,77 @@ +/* +Tab implementations +*/ +import { Job } from 'shared/types' +import * as SvgIcons from '../SvgIcons' +import { AddItemTabProps, ItemTabProps } from '../TabView' + +export function JobTab({ + item, + id, + tabpanelId, + isSelected, + onSelect, + onClose, + index, +}: ItemTabProps) { + let job = item // item is a Job + let label = job?.jobData?.nicename ?? 'New job' + + let onClose_ = (e, id) => { + e.stopPropagation() + onClose(id) + } + + return ( +
{ + onSelect(item) + }} + > + + +
+ ) +} + +export function AddJobTab({ + onSelect, + onItemWasCreated, +}: AddItemTabProps) { + return ( +
onSelect(onItemWasCreated)}> + +
+ ) +} diff --git a/ui/src/renderer/components/JobTabPanel/index.tsx b/ui/src/renderer/components/JobTabPanel/index.tsx new file mode 100644 index 0000000000..1979a7dbe2 --- /dev/null +++ b/ui/src/renderer/components/JobTabPanel/index.tsx @@ -0,0 +1,37 @@ +/* +Tab panel implementation +*/ +import { Job, JobState } from 'shared/types' +import { JobDetailsPane } from '../JobDetailsPane' +import { NewJobPane } from '../NewJobPane' +import { ItemTabPanelProps } from '../TabView' + +export function JobTabPanel({ + item, + isSelected, + id, + tabId, + updateItem, +}: ItemTabPanelProps) { + let job = item // item is a Job + let type = job.state == JobState.NEW ? 'script' : 'job' + + return ( + + ) +} diff --git a/ui/src/renderer/components/MainView/index.tsx b/ui/src/renderer/components/MainView/index.tsx new file mode 100644 index 0000000000..d822dca888 --- /dev/null +++ b/ui/src/renderer/components/MainView/index.tsx @@ -0,0 +1,203 @@ +/* +Data manager and owner of tab view +*/ +import { useState } from 'react' +import { Job, JobStatus, JobState, NamedResult } from 'shared/types/pipeline' +import { useQuery } from '@tanstack/react-query' +import { jobXmlToJson } from 'renderer/pipelineXmlConverter' +import { TabView } from '../TabView' +import { AddJobTab, JobTab } from '../JobTab' +import { JobTabPanel } from '../JobTabPanel' +import { useWindowStore } from 'renderer/store' +import { ipcRenderer } from 'electron' +import { IPC } from 'shared/constants' +import { join } from 'path' + +const NEW_JOB = (id) => ({ + internalId: id, + state: JobState.NEW, +}) + +const { App } = window + +export function MainView() { + const { settings } = useWindowStore() + const [jobs, setJobs] = useState(Array) + const [nextJobId, setNextJobId] = useState(0) + const [autoselect, setAutoselect] = useState(false) + const { isLoading, error, data } = useQuery( + ['jobsData'], + async () => { + let fetchJobData = async (job) => { + let res = await fetch(job.jobData.href) + let xmlStr = await res.text() + if (xmlStr) return jobXmlToJson(xmlStr) + else return null + } + + let updatedJobs = await Promise.all( + jobs.map(async (j) => { + // only check submitted jobs (e.g. have a jobData property) + // that are either IDLE or RUNNING (e.g. don't recheck ERROR or SUCCESS statuses) + if ( + j.hasOwnProperty('jobData') && + (j.jobData.status == JobStatus.IDLE || + j.jobData.status == JobStatus.RUNNING) + ) { + let jobData = await fetchJobData(j) + if (jobData.status != j.jobData.status) { + // download the available results + // And change file links + if (settings.downloadFolder && jobData.results) { + if (jobData.results.namedResults) { + console.log( + 'Fetching results ', + jobData.results.namedResults + ) + // const result of jobData.results.namedResults + // Note : the data used is the files field and not the + for ( + let i = + jobData.results.namedResults + .length - 1; + i >= 0; + --i + ) { + let namedResult = + jobData.results.namedResults[i] + const newJobURL = new URL( + `${settings.downloadFolder}/${jobData.jobId}/${namedResult.name}` + ).href + // change the target jobData href + jobData.results.namedResults[i].href = + newJobURL + if ( + jobData.results.namedResults[i] + .files + ) { + for ( + let j = + jobData.results + .namedResults[i].files + .length - 1; + j >= 0; + --j + ) { + let resultFile = + jobData.results.namedResults[i].files[j] + // Change the file url and keep the original href + const newFileURL = new URL( + `${ + settings.downloadFolder + }/${jobData.jobId}/${ + namedResult.name + }/${resultFile.file + .split('/') + .pop()}` + ).href + let fetchedResult = await fetch( + resultFile.href + ) + .then((response) => + response.blob() + ) + .then((blob) => + blob.arrayBuffer() + ) + .then((buffer) => + resultFile.mimeType === + 'application/zip' + ? App.unzipFile( + buffer, + newFileURL + ) + : App.saveFile( + buffer, + newFileURL + ) + ) + .then(() => { + let newResult = + Object.assign( + {}, + resultFile + ) + newResult.file = + newFileURL + return newResult + }) + .catch((e) => resultFile) // if a problem occured, return the original result + .finally() + jobData.results.namedResults[i].files[j].file = + fetchedResult.file + } + } + } + jobData.results.href = new URL( + `${settings.downloadFolder}/${jobData.jobId}` + ).href + } + } + } + j.jobData = jobData + } + return j + }) + ) + if (jobs.length == 0) { + updatedJobs.push(NEW_JOB(`job-${nextJobId}`)) + setNextJobId(nextJobId + 1) + } + setJobs([...updatedJobs]) + return updatedJobs + }, + { refetchInterval: 3000 } + ) + + if (isLoading) { + return <> + } + if (error instanceof Error) { + console.log('Error', error) + return <> + } + if (!data) { + return <> + } + + let addJob = (onItemWasCreated?) => { + let theNewJob = NEW_JOB(`job-${nextJobId}`) + setJobs([...jobs, theNewJob]) + setNextJobId(nextJobId + 1) + if (onItemWasCreated) { + onItemWasCreated(theNewJob.internalId) + } + } + + let removeJob = (jobId) => { + const jobs_ = jobs.filter((j) => j.internalId !== jobId) + setJobs(jobs_) + } + + let updateJob = (job) => { + let jobId = job.internalId + let jobs_ = jobs.map((j) => { + if (j.internalId == jobId) { + return { ...job } + } else return j + }) + setJobs(jobs_) + } + + return ( + + items={jobs} + onTabCreate={addJob} + onTabClose={removeJob} + ItemTab={JobTab} + AddItemTab={AddJobTab} + ItemTabPanel={JobTabPanel} + updateItem={updateJob} + /> + ) +} diff --git a/ui/src/renderer/components/NewJobPane/index.tsx b/ui/src/renderer/components/NewJobPane/index.tsx new file mode 100644 index 0000000000..5aa56141a9 --- /dev/null +++ b/ui/src/renderer/components/NewJobPane/index.tsx @@ -0,0 +1,53 @@ +/* +Select a script and submit a new job +*/ +import { useState } from 'react' +import { ScriptForm } from '../ScriptForm' +import { useWindowStore } from 'renderer/store' +import { ID } from 'renderer/utils/utils' + +export function NewJobPane({ job, updateJob }) { + const [selectedScript, setSelectedScript] = useState(null) + const { scripts } = useWindowStore() + + let onSelectChange = (e) => { + let selection = scripts.find((script) => script.id == e.target.value) + setSelectedScript(selection) + } + + let job_ = { ...job } + return ( + <> +
+ + +
+ {selectedScript != null ? ( + + ) : ( + '' + )} + + ) +} diff --git a/ui/src/renderer/components/ScriptForm/index.tsx b/ui/src/renderer/components/ScriptForm/index.tsx new file mode 100644 index 0000000000..ba0d5653d6 --- /dev/null +++ b/ui/src/renderer/components/ScriptForm/index.tsx @@ -0,0 +1,305 @@ +/* +Fill out fields for a new job and submit it +*/ +import { jobRequestToXml, jobXmlToJson } from 'renderer/pipelineXmlConverter' +import { JobRequest, JobState, baseurl, ScriptItemBase } from 'shared/types' +import { useState, useEffect } from 'react' +import { useWindowStore } from 'renderer/store' +import { + findInputType, + findValue, + getAllOptional, + getAllRequired, + ID, +} from 'renderer/utils/utils' +import { Section } from '../Section' +import { marked } from 'marked' +import { FileOrFolderInput } from '../CustomFields/FileOrFolderInput' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +const { App } = window + +export function ScriptForm({ job, script, updateJob }) { + const [submitInProgress, setSubmitInProgress] = useState(false) + const [error, setError] = useState(false) + const { pipeline } = useWindowStore() + + const [jobRequest, setJobRequest] = useState(null) + useEffect(() => { + setJobRequest({ + scriptHref: script.href, + nicename: script.nicename, + inputs: script.inputs.map((item) => ({ + name: item.name, + value: null, + isFile: item.type == 'anyFileURI' || item.type == 'anyDirURI', + })), + options: script.options.map((item) => ({ + name: item.name, + value: item.default ? item.default : null, + isFile: item.type == 'anyFileURI' || item.type == 'anyDirURI', + })), + }) + }, [script]) + let required = getAllRequired(script) + let optional = getAllOptional(script) + + // take input from the form and add it to the job request + let saveValueInJobRequest = (value, data) => { + if (!jobRequest) { + return + } + let inputs = [...jobRequest.inputs] + let options = [...jobRequest.options] + + // update the array and return a new copy of it + let updateValue = (value, data, arr) => { + let arr2 = arr.map((i) => + i.name == data.name ? { ...i, value } : i + ) + return arr2 + } + if (data.kind == 'input') { + inputs = updateValue(value, data, inputs) + } else { + options = updateValue(value, data, options) + } + let newJobRequest = { + ...jobRequest, + inputs: [...inputs], + options: [...options], + } + + setJobRequest(newJobRequest) + } + + // submit a job + let onSubmit = async (e) => { + e.preventDefault() + setSubmitInProgress(true) + + let xmlStr = jobRequestToXml(jobRequest) + + // this post request submits the job to the pipeline webservice + let res = await fetch(`${baseurl(pipeline.runningWebservice)}/jobs`, { + method: 'POST', + body: xmlStr, + mode: 'cors', + }) + setSubmitInProgress(false) + if (res.status != 201) { + setError(true) + } else { + let newJobXml = await res.text() + try { + let newJobJson = jobXmlToJson(newJobXml) + let job_ = { + ...job, + state: JobState.SUBMITTED, + jobData: newJobJson, + jobRequest, + script, + } + updateJob(job_) + } catch (err) { + setError(true) + } + } + } + + return ( + <> +
+
+

+ {script?.nicename} +

+

{script?.description}

+
+ + {error ?

Error

: ''} +
+ + {!submitInProgress ? ( +
+
+
    + {required.map((item, idx) => ( +
  • + +
  • + ))} +
+
+ {optional.length > 0 ? ( +
+
    + {optional.map((item, idx) => ( +
  • + +
  • + ))} +
+
+ ) : ( + '' + )} +
+ ) : ( + <> +

Submitting...

+ {error ?

Error

: ''} + + )} + + ) +} + +// create a form element for the item +// item.type can be: +// anyFileURI, anyDirURI, xsd:string, xsd:dateTime, xsd:boolean, xsd:integer, xsd:float, xsd:double, xsd:decimal +// item.mediaType is a file type e.g. application/x-dtbook+xml +function FormField({ + item, + idprefix, + onChange, + initialValue, +}: { + item: ScriptItemBase + idprefix: string + onChange: (string, ScriptItemBase) => void // function to set the value in a parent-level collection. + initialValue: any // the initialValue +}) { + let inputType = findInputType(item.type) + const [value, setValue] = useState(initialValue) + let controlId = `${idprefix}-${item.name}` + + let onFileFolderChange = (filename, data) => { + console.log('onFileFolderChange', filename) + onChange(filename, data) + } + let onInputChange = (e, data) => { + let newValue = + e.target.getAttribute('type') == 'checkbox' + ? e.target.checked + : e.target.value + setValue(newValue) + onChange(newValue, data) + } + let dialogOpts = + item.type == 'anyFileURI' + ? ['openFile'] + : item.type == 'anyDirURI' + ? ['openDirectory'] + : ['openFile', 'openDirectory'] + + let externalLinkClick = (e) => { + e.preventDefault() + App.openInBrowser(e.target.href) + } + + return ( +
+ + + { + return ( + + {props.children} + + ) + }, + }} + > + {item.desc} + + + + {inputType == 'file' ? ( // 'item' may be an input or an option + onFileFolderChange(filename, item)} + useSystemPath={false} + buttonLabel="Browse" + required={item.required} + initialValue={initialValue} + /> + ) : inputType == 'checkbox' ? ( // 'item' is an option + onInputChange(e, item)} + id={controlId} + checked={value === 'true' || value === true} + > + ) : ( + // 'item' is an option + onInputChange(e, item)} + > + )} +
+ ) +} diff --git a/ui/src/renderer/components/Section/index.tsx b/ui/src/renderer/components/Section/index.tsx new file mode 100644 index 0000000000..5c246e8698 --- /dev/null +++ b/ui/src/renderer/components/Section/index.tsx @@ -0,0 +1,11 @@ +/* +Generic section labelled by an h2 +*/ +export function Section({ label, id, className, children }) { + return ( +
+

{label}

+ {children} +
+ ) +} diff --git a/ui/src/renderer/components/SettingsForm/index.tsx b/ui/src/renderer/components/SettingsForm/index.tsx new file mode 100644 index 0000000000..2c9e048585 --- /dev/null +++ b/ui/src/renderer/components/SettingsForm/index.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { useWindowStore } from 'renderer/store' +import { ApplicationSettings } from 'shared/types' +import { FileOrFolderInput } from '../CustomFields/FileOrFolderInput' + +const { App } = window // The "App" comes from the bridge + +export function SettingsForm() { + // Current registered settings + const { settings } = useWindowStore() + // Copy settings in new settings + const [newSettings, setNewSettings] = useState({ + ...settings, + }) + const [saved, setSaved] = useState(true) + useEffect(() => { + setNewSettings({ + ...settings, + }) + }, [settings]) + // Changed folder + const resultsFolderChanged = (filename) => { + setNewSettings({ + ...newSettings, + downloadFolder: filename, + }) + setSaved(false) + } + + // send back the settings for being save on disk + const handleSave = () => { + App.saveSettings(newSettings) + setSaved(true) + } + return ( +
+
+
+ + + A folder where all jobs will be automatically downloaded + + +
+ {/* insert local pipeline settings form part here */} + {/* insert remote pipeline settings form part here */} +
+
+ {' '} + + {saved ? ( + + Saved + + ) : ( + '' + )} +
+
+ ) +} diff --git a/ui/src/renderer/components/SvgIcons/index.tsx b/ui/src/renderer/components/SvgIcons/index.tsx new file mode 100644 index 0000000000..b2aa6682b8 --- /dev/null +++ b/ui/src/renderer/components/SvgIcons/index.tsx @@ -0,0 +1,40 @@ +/* +A few simple icons +*/ +export function CloseTab({ width, height }) { + return ( + + + + + ) +} + +export function AddTab({ width, height }) { + return ( + + + + + ) +} diff --git a/ui/src/renderer/components/TabView/index.tsx b/ui/src/renderer/components/TabView/index.tsx new file mode 100644 index 0000000000..aba03ac1b2 --- /dev/null +++ b/ui/src/renderer/components/TabView/index.tsx @@ -0,0 +1,130 @@ +/* +Generic tab view component with tab list and tab panels +Hooks for adding, removing, updating items +Implementation should provide custom display components for rendering tab and panel contents +*/ +import { useState, useEffect } from 'react' +import { ID } from 'renderer/utils/utils' + +export interface TabViewProps { + items: T[] + ItemTab: React.FunctionComponent> + AddItemTab: React.FunctionComponent> + ItemTabPanel: React.FunctionComponent> + onTabClose: Function + onTabCreate: Function + updateItem: Function +} + +export interface ItemTabProps { + item: T + id: string + tabpanelId: string + isSelected: boolean + onSelect: Function + onClose: Function + index: number +} + +export interface AddItemTabProps { + onSelect: Function + onItemWasCreated: Function +} + +export interface ItemTabPanelProps { + id: string + tabId: string + item: T + isSelected: boolean + updateItem: Function +} + +export function TabView( + props: TabViewProps +) { + const { + items, + ItemTab, + AddItemTab, + ItemTabPanel, + onTabClose, + onTabCreate, + updateItem, + } = props + + const [selectedItemId, setSelectedItemId] = useState('') + const [selectedItem, setSelectedItem] = useState(null) + useEffect(() => { + // when items changes, see if we need to reconsider which tab is selected + // this is slightly imperfect as sometimes the user's current tab is switched when they close another one + // TODO: revisit + + if (selectedItemId == '' && items.length > 0 && items[0].internalId) { + setSelectedItemId(items[0].internalId) + } else if (!items.map((i) => i.internalId).includes(selectedItemId)) { + if (items.length > 0) + setSelectedItemId(items[items.length - 1].internalId) + } + + let sel = items.find((i) => i.internalId == selectedItemId) + setSelectedItem(sel) + }, [items]) + + let onTabSelect = (item) => { + setSelectedItemId(item.internalId) + } + + let onTabClose_ = (id) => { + if (items.length > 1) { + let newSelectedIndex = items.length - 2 + let newId = items[newSelectedIndex].internalId + setSelectedItemId(newId) + onTabClose(id) + } else if (items.length == 1) { + onTabClose(id) + setSelectedItemId(items[0].internalId) + } else { + setSelectedItemId('') + } + } + + // when a tab is created, its new ID is reported back here so it can be selected automatically + let onItemWasCreated = (newId) => { + setSelectedItemId(newId) + } + + return ( + <> +
+ {items.map((item, idx) => ( + onTabClose_(item.internalId)} + /> + ))} + +
+ {items.map((item, idx) => { + return ( + + ) + })} + + ) +} diff --git a/ui/src/renderer/components/index.ts b/ui/src/renderer/components/index.ts new file mode 100644 index 0000000000..f0bf941b4e --- /dev/null +++ b/ui/src/renderer/components/index.ts @@ -0,0 +1,2 @@ +export * from './MainView' +export * from './SvgIcons' diff --git a/ui/src/renderer/index.html b/ui/src/renderer/index.html new file mode 100644 index 0000000000..8b8c7d9536 --- /dev/null +++ b/ui/src/renderer/index.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/src/renderer/index.tsx b/ui/src/renderer/index.tsx new file mode 100644 index 0000000000..8fe8d79ce3 --- /dev/null +++ b/ui/src/renderer/index.tsx @@ -0,0 +1,15 @@ +import ReactDom from 'react-dom/client' +import React from 'react' + +import { WindowStoreProvider } from './store' +import { AppRoutes } from './routes' + +import './style/style.scss' + +ReactDom.createRoot(document.querySelector('app') as HTMLElement).render( + + + + + +) diff --git a/ui/src/renderer/pipelineXmlConverter/aliveToJson.ts b/ui/src/renderer/pipelineXmlConverter/aliveToJson.ts new file mode 100644 index 0000000000..18d12e6b57 --- /dev/null +++ b/ui/src/renderer/pipelineXmlConverter/aliveToJson.ts @@ -0,0 +1,21 @@ +import { Alive } from 'shared/types' +import { parseXml } from './parser' + +function aliveXmlToJson(xmlString: string): Alive { + try { + let aliveElm = parseXml(xmlString, 'alive') + return { + alive: true, + localfs: aliveElm[0].getAttribute('localfs') == 'true', + authentication: + aliveElm[0].getAttribute('authentication') == 'true', + version: aliveElm[0].getAttribute('version'), + } + } catch (err) { + return { + alive: false, + } + } +} + +export { aliveXmlToJson } diff --git a/ui/src/renderer/pipelineXmlConverter/index.ts b/ui/src/renderer/pipelineXmlConverter/index.ts new file mode 100644 index 0000000000..7818479f29 --- /dev/null +++ b/ui/src/renderer/pipelineXmlConverter/index.ts @@ -0,0 +1,8 @@ +// Note: eventually, it would be nice to get JSON directly from the Pipeline WS +// Until then, these functions will provide translation from XML to JSON for Pipeline WS response documents +export * from './jobsToJson' +export * from './jobToJson' +export * from './scriptToJson' +export * from './scriptsToJson' +export * from './aliveToJson' +export * from './jobRequestToXml' diff --git a/ui/src/renderer/pipelineXmlConverter/jobRequestToXml.ts b/ui/src/renderer/pipelineXmlConverter/jobRequestToXml.ts new file mode 100644 index 0000000000..d62b0805eb --- /dev/null +++ b/ui/src/renderer/pipelineXmlConverter/jobRequestToXml.ts @@ -0,0 +1,24 @@ +import { JobRequest } from 'shared/types/pipeline' + +function jobRequestToXml(jobRequest: JobRequest): string { + let xmlString = ` + + ${jobRequest.nicename} + medium +