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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+ - Input HTMLfile:///Users/marisa/dev/file.html
+ - Style sheets value
+ - Style sheet parameters value
+ - Braille code value
+ - Transformer features value
+ - Include preview value
+ - Include PEF value
+ - Include OBFL value
+ - Output file format value
+ - ASCII braille table for HTML preview value
+ - Page layout: Page width value
+ - Page layout: Page height value
+ - Page layout: Duplex value
+ - Headers/footers: Levels in footer value
+ - Translation/formatting of text: Hyphenation value
+ - Translation/formatting of text: Hyphenation at page boundaries
+ value
+ - Translation/formatting of text: Line spacing value
+ - Translation/formatting of text: Capital letters value
+ - Block elements: Include captions value
+ - Block elements: Include images value
+ - Block elements: Include line groups value
+ - Inline elements: Include production notes value
+ - Page numbers: Show braille page numbers value
+ - Page numbers: Show print page numbers value
+ - Page numbers: Force braille page break value
+ - Table of contents: Table of contents depth value
+ - Table of contents: Exclude headings value
+ - Volumes: Maximum number of sheets value
+ - Volumes: Allow breaks within sections. value
+ - Volumes: Prefer breaks at higher level sections. value
+ - Placement of content: Notes placement value
+
+
+
+ Messages
+
+ - NaN - INFO: Loading DAISY 3
+ - NaN - INFO: Converting DTBook to XHTML
+ - NaN - INFO: Converting DTBook to ZedAI
+ - NaN - TRACE: Lorem ipsum dolor sit, amet consectetur
+ - NaN - TRACE: Lorem ipsum dolor sit, amet consectetur
+ - NaN - INFO: Upgrading DTBook to 2005-3
+ - NaN - INFO: Validating DTBook
+ - NaN - INFO: Validating
+ - NaN - INFO: Generating MODS metadata
+ - NaN - INFO: Generating ZedAI metadata
+ - NaN - DEBUG: Lorem ipsum dolor sit, amet consectetur
+ - NaN - INFO: Generating CSS
+ - NaN - INFO: Adding CSS PI
+ - NaN - INFO: Validating ZedAI
+ - NaN - INFO: Converting ZedAI to XHTML 5
+ - NaN - WARNING: Lorem ipsum dolor sit, amet consectetur
+ - NaN - INFO: Converting NCX to EPUB navigation document
+ - NaN - ERROR: Lorem ipsum dolor sit, amet consectetur
+ - NaN - ERROR: Lorem ipsum dolor sit, amet consectetur
+ - NaN - INFO: Creating EPUB media overlay documents
+ - NaN - INFO: Creating EPUB package document
+ - NaN - INFO: Discarding property 'dcterms:modified' with value
+ '2022-11-02T00:55:48Z'. A new
+ value is generated.
+ - NaN - INFO: Storing EPUB 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 (
+
+
+ {job.state == JobState.NEW ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
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 ? (
+
+ ) : (
+ <>
+ 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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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
+
+ ${jobRequest.inputs
+ .map(
+ (input) =>
+ ` `
+ )
+ .join('')}
+ ${jobRequest.options
+ .map(
+ (option) => ``
+ )
+ .join('')}
+ `
+ return xmlString
+}
+
+export { jobRequestToXml }
diff --git a/ui/src/renderer/pipelineXmlConverter/jobToJson.ts b/ui/src/renderer/pipelineXmlConverter/jobToJson.ts
new file mode 100644
index 0000000000..96efe50b54
--- /dev/null
+++ b/ui/src/renderer/pipelineXmlConverter/jobToJson.ts
@@ -0,0 +1,122 @@
+import {
+ NamedResult,
+ Results,
+ ResultFile,
+ Priority,
+ JobStatus,
+ JobData,
+ MessageLevel,
+} from 'shared/types/pipeline'
+import { scriptElementToJson } from './scriptToJson'
+import { parseXml } from './parser'
+
+function jobXmlToJson(xmlString: string): JobData {
+ let jobElm = parseXml(xmlString, 'job')
+ return jobElementToJson(jobElm)
+}
+
+function jobElementToJson(jobElm: Element): JobData {
+ let jobData: JobData = {
+ jobId: jobElm.getAttribute('id'),
+ priority:
+ Priority[jobElm.getAttribute('priority') as keyof typeof Priority],
+ status: JobStatus[
+ jobElm.getAttribute('status') as keyof typeof JobStatus
+ ],
+ href: jobElm.getAttribute('href'),
+ }
+
+ // TODO is nicename an element or attribute on ?
+ // not sure at the moment so just check for it in both places
+ let nicenameElms = jobElm.getElementsByTagName('nicename')
+ if (nicenameElms.length > 0) {
+ jobData.nicename = nicenameElms[0].textContent
+ } else if (jobElm.hasAttribute('nicename')) {
+ jobData.nicename = jobElm.getAttribute('nicename')
+ } else {
+ jobData.nicename = 'Job'
+ }
+ let logElms = jobElm.getElementsByTagName('log')
+ if (logElms.length > 0) {
+ jobData.log = logElms[0].getAttribute('href')
+ }
+ let resultsElms = jobElm.getElementsByTagName('results')
+ if (resultsElms.length > 0) {
+ let results: Results = {
+ href: resultsElms[0].getAttribute('href'),
+ mimeType: resultsElms[0].getAttribute('mime-type'),
+ namedResults: [],
+ }
+ results.namedResults = Array.from(
+ resultsElms[0].getElementsByTagName('result')
+ )
+ // filter out non-direct children
+ .filter((resultElm) => resultElm.parentElement == resultsElms[0])
+ .map((resultElm): NamedResult => {
+ let namedResult: NamedResult = {
+ from: resultElm.getAttribute('from'),
+ href: resultElm.getAttribute('href'),
+ mimeType: resultElm.getAttribute('mime-type'),
+ name: resultElm.getAttribute('name'),
+ nicename: resultElm.getAttribute('nicename'),
+ files: [],
+ }
+ // the results are structured so that a "result" element is nested inside another "result" element
+ // and they have different attributes
+ // @ts-ignore
+ namedResult.files = Array.from(
+ resultElm.getElementsByTagName('result')
+ ).map((resultFileElm) => {
+ let resultFile: ResultFile = {
+ mimeType: resultFileElm.getAttribute('mime-type'),
+ size: resultFileElm.getAttribute('size'),
+ }
+ if (resultFileElm.hasAttribute('file')) {
+ // @ts-ignore
+ resultFile.file = resultFileElm.getAttribute('file')
+ }
+ if (resultFileElm.hasAttribute('href')) {
+ // @ts-ignore
+ resultFile.href = resultFileElm.getAttribute('href')
+ }
+ return resultFile
+ })
+ return namedResult
+ })
+ jobData.results = results
+ }
+ let messagesElms = jobElm.getElementsByTagName('messages')
+ if (messagesElms.length > 0) {
+ //@ts-ignore
+ jobData.messages = Array.from(
+ messagesElms[0].getElementsByTagName('message')
+ ).map((messageElm) => {
+ let timestamp = messageElm.getAttribute('timeStamp')
+ try {
+ timestamp = new Date(
+ parseInt(messageElm.getAttribute('timeStamp'))
+ ).toISOString()
+ } catch (err) {
+ console.log(err)
+ }
+ return {
+ level: MessageLevel[
+ messageElm.getAttribute(
+ 'level'
+ ) as keyof typeof MessageLevel
+ ],
+ content: messageElm.getAttribute('content'),
+ sequence: parseInt(messageElm.getAttribute('sequence')),
+ timestamp,
+ }
+ })
+ jobData.progress = parseInt(messagesElms[0].getAttribute('progress'))
+ }
+ let scriptElms = jobElm.getElementsByTagName('script')
+ if (scriptElms.length > 0) {
+ jobData.script = scriptElementToJson(scriptElms[0])
+ }
+ return jobData
+}
+
+export { jobXmlToJson, jobElementToJson }
diff --git a/ui/src/renderer/pipelineXmlConverter/jobsToJson.ts b/ui/src/renderer/pipelineXmlConverter/jobsToJson.ts
new file mode 100644
index 0000000000..17371728e4
--- /dev/null
+++ b/ui/src/renderer/pipelineXmlConverter/jobsToJson.ts
@@ -0,0 +1,14 @@
+import { Job } from 'shared/types'
+import { jobElementToJson } from './jobToJson'
+import { parseXml } from './parser'
+
+function jobsXmlToJson(xmlString: string): Array {
+ let jobsElm = parseXml(xmlString, 'jobs')
+ let jobs = Array.from(jobsElm.getElementsByTagName('job')).map((jobElm) => {
+ let job = jobElementToJson(jobElm)
+ return job
+ })
+ return jobs
+}
+
+export { jobsXmlToJson }
diff --git a/ui/src/renderer/pipelineXmlConverter/parser.ts b/ui/src/renderer/pipelineXmlConverter/parser.ts
new file mode 100644
index 0000000000..408dad987a
--- /dev/null
+++ b/ui/src/renderer/pipelineXmlConverter/parser.ts
@@ -0,0 +1,12 @@
+// parse a string of xml and return the first element with the given name
+export function parseXml(xmlString, elmName) {
+ let doc = new DOMParser().parseFromString(xmlString, 'text/xml')
+ if (!doc) {
+ throw new Error(`Could not parse XML for ${elmName}`)
+ }
+ let elm = doc.getElementsByTagName(elmName)
+ if (!elm || elm.length == 0) {
+ throw new Error(`Element ${elmName} not found`)
+ }
+ return elm[0]
+}
diff --git a/ui/src/renderer/pipelineXmlConverter/scriptToJson.ts b/ui/src/renderer/pipelineXmlConverter/scriptToJson.ts
new file mode 100644
index 0000000000..79d8d9c7db
--- /dev/null
+++ b/ui/src/renderer/pipelineXmlConverter/scriptToJson.ts
@@ -0,0 +1,67 @@
+import { Script, ScriptInput, ScriptOption } from 'shared/types/pipeline'
+import { parseXml } from './parser'
+
+function scriptXmlToJson(xmlString: string): Script {
+ let scriptElm = parseXml(xmlString, 'script')
+ return scriptElementToJson(scriptElm)
+}
+
+function scriptElementToJson(scriptElm: Element): Script {
+ let nicenameElm = scriptElm.getElementsByTagName('nicename')
+ let descriptionElm = scriptElm.getElementsByTagName('description')
+ let versionElm = scriptElm.getElementsByTagName('version')
+
+ let script: Script = {
+ id: scriptElm.getAttribute('id'),
+ href: scriptElm.getAttribute('href'),
+ nicename: (nicenameElm[0] as Element).textContent,
+ description: (descriptionElm[0] as Element).textContent,
+ version: (versionElm[0] as Element).textContent,
+ }
+
+ script.inputs = Array.from(scriptElm.getElementsByTagName('input')).map(
+ (inputElm): ScriptInput => {
+ let mediaType = []
+ let mediaTypeVal = inputElm.getAttribute('mediaType')
+ if (mediaTypeVal) {
+ mediaType = mediaTypeVal.split(' ')
+ }
+ return {
+ desc: inputElm.getAttribute('desc'),
+ mediaType,
+ name: inputElm.getAttribute('name'),
+ sequence: inputElm.getAttribute('sequence') == 'true',
+ required: inputElm.getAttribute('required') == 'true',
+ nicename: inputElm.getAttribute('nicename'),
+ type: 'anyFileURI',
+ kind: 'input',
+ }
+ }
+ )
+
+ script.options = Array.from(scriptElm.getElementsByTagName('option')).map(
+ (optionElm): ScriptOption => {
+ let mediaType = []
+ let mediaTypeVal = optionElm.getAttribute('mediaType')
+ if (mediaTypeVal) {
+ mediaType = mediaTypeVal.split(' ')
+ }
+ return {
+ desc: optionElm.getAttribute('desc'),
+ mediaType,
+ name: optionElm.getAttribute('name'),
+ sequence: optionElm.getAttribute('sequence') == 'true',
+ required: optionElm.getAttribute('required') == 'true',
+ nicename: optionElm.getAttribute('nicename'),
+ ordered: optionElm.getAttribute('ordered') == 'true',
+ type: optionElm.getAttribute('type'),
+ default: optionElm.getAttribute('default'),
+ kind: 'option',
+ }
+ }
+ )
+
+ return script
+}
+
+export { scriptXmlToJson, scriptElementToJson }
diff --git a/ui/src/renderer/pipelineXmlConverter/scriptsToJson.ts b/ui/src/renderer/pipelineXmlConverter/scriptsToJson.ts
new file mode 100644
index 0000000000..9aeb353de4
--- /dev/null
+++ b/ui/src/renderer/pipelineXmlConverter/scriptsToJson.ts
@@ -0,0 +1,15 @@
+import { scriptElementToJson } from './scriptToJson'
+import { Script } from 'shared/types'
+import { parseXml } from './parser'
+
+function scriptsXmlToJson(xmlString: string): Array