diff --git a/client/src/integration-test/kotlin/net/corda/client/CordaRPCClientTest.kt b/client/src/integration-test/kotlin/net/corda/client/CordaRPCClientTest.kt
index 153fb670fd..4b071b2c87 100644
--- a/client/src/integration-test/kotlin/net/corda/client/CordaRPCClientTest.kt
+++ b/client/src/integration-test/kotlin/net/corda/client/CordaRPCClientTest.kt
@@ -14,7 +14,6 @@ import net.corda.node.services.User
import net.corda.node.services.messaging.CordaRPCClient
import net.corda.node.services.startFlowPermission
import net.corda.node.services.transactions.ValidatingNotaryService
-import net.corda.testing.configureTestSSL
import net.corda.testing.node.NodeBasedTest
import org.apache.activemq.artemis.api.core.ActiveMQSecurityException
import org.assertj.core.api.Assertions.assertThatExceptionOfType
diff --git a/settings.gradle b/settings.gradle
index 11a8b4a211..c599281ae4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -16,6 +16,8 @@ include 'experimental'
include 'experimental:sandbox'
include 'test-utils'
include 'tools:explorer'
+include 'tools:explorer:capsule'
+include 'tools:demobench'
include 'tools:loadtest'
include 'docs/source/example-code' // Note that we are deliberately choosing to use '/' here. With ':' gradle would treat the directories as actual projects.
include 'samples:attachment-demo'
diff --git a/tools/demobench/build.gradle b/tools/demobench/build.gradle
new file mode 100644
index 0000000000..26e641643c
--- /dev/null
+++ b/tools/demobench/build.gradle
@@ -0,0 +1,205 @@
+buildscript {
+ ext.tornadofx_version = '1.6.2'
+ ext.jna_version = '4.1.0'
+ ext.purejavacomm_version = '0.0.17'
+ ext.guava_version = '14.0.1'
+ ext.controlsfx_version = '8.40.12'
+
+ ext.java_home = System.properties.'java.home'
+ ext.pkg_source = "$buildDir/packagesrc"
+ ext.pkg_outDir = "$buildDir/javapackage"
+ ext.dist_source = "$pkg_source/demobench-$version"
+ ext.pkg_version = "$version".indexOf('-') >= 0 ? "$version".substring(0, "$version".indexOf('-')) : version
+
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'java'
+apply plugin: 'kotlin'
+apply plugin: 'application'
+
+evaluationDependsOn(':tools:explorer:capsule')
+
+mainClassName = 'net.corda.demobench.DemoBench'
+applicationDefaultJvmArgs = ['-Djava.util.logging.config.class=net.corda.demobench.config.LoggingConfig', '-Dorg.jboss.logging.provider=slf4j']
+
+repositories {
+ flatDir {
+ dirs 'libs'
+ }
+
+ mavenLocal()
+ mavenCentral()
+ jcenter()
+ maven {
+ url 'http://www.sparetimelabs.com/maven2'
+ }
+ maven {
+ url 'https://dl.bintray.com/kotlin/exposed'
+ }
+}
+
+dependencies {
+ // TornadoFX: A lightweight Kotlin framework for working with JavaFX UI's.
+ compile "no.tornado:tornadofx:$tornadofx_version"
+
+ // Controls FX: more java FX components http://fxexperience.com/controlsfx/
+ compile "org.controlsfx:controlsfx:$controlsfx_version"
+
+ // ONLY USING THE RPC CLIENT!?
+ compile (project(':node')) {
+ exclude module: 'kotlin-test'
+ exclude module: 'junit'
+ }
+
+ compile "com.h2database:h2:$h2_version"
+ compile "net.java.dev.jna:jna-platform:$jna_version"
+ compile "com.google.guava:guava:$guava_version"
+ compile "com.sparetimelabs:purejavacomm:$purejavacomm_version"
+ compile "org.slf4j:log4j-over-slf4j:$slf4j_version"
+ compile "org.slf4j:jul-to-slf4j:$slf4j_version"
+ compile "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version"
+ compile "org.apache.logging.log4j:log4j-core:$log4j_version"
+ compile "com.typesafe:config:$typesafe_config_version"
+
+ // These libraries don't exist in any Maven repository I can find.
+ // See: https://github.com/JetBrains/jediterm
+ //
+ // The terminal JAR here has also been tweaked:
+ // See: https://github.com/JetBrains/jediterm/issues/144
+ compile ':jediterm-terminal-2.5'
+ compile ':pty4j-0.7.2'
+
+ testCompile "junit:junit:$junit_version"
+ testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
+}
+
+jar {
+ manifest {
+ attributes(
+ 'Corda-Version': corda_version,
+ 'Main-Class': mainClassName,
+ 'Class-Path': configurations.compile.collect { it.getName() }.join(' ')
+ )
+ }
+}
+
+test {
+ systemProperty 'java.util.logging.config.class', 'net.corda.demobench.config.LoggingConfig'
+ systemProperty 'org.jboss.logging.provider', 'slf4j'
+}
+
+distributions {
+ main() {
+ contents {
+ into('lib/linux') {
+ from 'libs/linux'
+ }
+ into('lib/macosx') {
+ from 'libs/macosx'
+ }
+ into('lib/win') {
+ from 'libs/win'
+ }
+ from(project(':tools:explorer:capsule').tasks.buildExplorerJAR) {
+ rename 'node-explorer-(.*)', 'node-explorer.jar'
+ into 'explorer'
+ }
+ from(project(':node:capsule').tasks.buildCordaJAR) {
+ rename 'corda-(.*)', 'corda.jar'
+ into 'corda'
+ }
+ from(project(':node:webserver:webcapsule').tasks.buildWebserverJar) {
+ rename 'corda-webserver-(.*)', 'corda-webserver.jar'
+ into 'corda'
+ }
+ from(project(':samples:bank-of-corda-demo').jar) {
+ rename 'bank-of-corda-demo-(.*)', 'bank-of-corda.jar'
+ into 'plugins'
+ }
+ }
+ }
+}
+
+/*
+ * Bundles the application using JavaPackager,
+ * using the ZIP distribution as source.
+ */
+task javapackage(dependsOn: 'distZip') {
+
+ doLast {
+ delete([pkg_source, pkg_outDir])
+
+ copy {
+ from(zipTree(distZip.outputs.files.singleFile))
+ into pkg_source
+ }
+
+ copy {
+ /*
+ * Copy non-text formats "as-is".
+ */
+ from("$projectDir/package") {
+ exclude '**/*.spec'
+ exclude '**/*.sh'
+ exclude '**/*.wsf'
+ exclude '**/*.manifest'
+ }
+ into "$pkg_source/package"
+ }
+
+ copy {
+ /*
+ * Expand tokens for text formats.
+ */
+ from("$projectDir/package") {
+ include '**/*.spec'
+ include '**/*.sh'
+ include '**/*.wsf'
+ include '**/*.manifest'
+ }
+ filter {
+ line -> line.replaceAll('@pkg_version@', pkg_version)
+ }
+ into "$pkg_source/package"
+ }
+
+ ant.taskdef(
+ resource: 'com/sun/javafx/tools/ant/antlib.xml',
+ classpath: "$pkg_source:$java_home/../lib/ant-javafx.jar"
+ )
+
+ ant.deploy(nativeBundles: packageType, outdir: pkg_outDir, outfile: 'DemoBench', verbose: 'true') {
+ application(name: 'DemoBench', version: pkg_version, mainClass: mainClassName)
+ info(title: 'DemoBench', vendor: 'R3', description: 'A sales and educational tool for demonstrating Corda.')
+ resources {
+ fileset(dir: "$dist_source/lib", type: 'jar') {
+ include(name: '*.jar')
+ }
+
+ fileset(dir: "$dist_source/lib", type: 'native') {
+ include(name: "macosx/**/*.dylib")
+ include(name: "win/**/*.dll")
+ include(name: "win/**/*.exe")
+ include(name: "linux/**/*.so")
+ }
+
+ fileset(dir: dist_source, type: 'data') {
+ include(name: 'corda/*.jar')
+ include(name: 'plugins/*.jar')
+ include(name: 'explorer/*.jar')
+ }
+ }
+
+ platform {
+ property(name: 'java.util.logging.config.class', value: 'net.corda.demobench.config.LoggingConfig')
+ property(name: 'org.jboss.logging.provider', value: 'slf4j')
+ }
+
+ preferences(install: false)
+ }
+ }
+}
diff --git a/tools/demobench/libs/jediterm-terminal-2.5.jar b/tools/demobench/libs/jediterm-terminal-2.5.jar
new file mode 100644
index 0000000000..b211b2d08e
Binary files /dev/null and b/tools/demobench/libs/jediterm-terminal-2.5.jar differ
diff --git a/tools/demobench/libs/linux/x86/libpty.so b/tools/demobench/libs/linux/x86/libpty.so
new file mode 100755
index 0000000000..832504bc8d
Binary files /dev/null and b/tools/demobench/libs/linux/x86/libpty.so differ
diff --git a/tools/demobench/libs/linux/x86_64/libpty.so b/tools/demobench/libs/linux/x86_64/libpty.so
new file mode 100755
index 0000000000..cd41f3b15b
Binary files /dev/null and b/tools/demobench/libs/linux/x86_64/libpty.so differ
diff --git a/tools/demobench/libs/macosx/x86/libpty.dylib b/tools/demobench/libs/macosx/x86/libpty.dylib
new file mode 100755
index 0000000000..f05fb026cf
Binary files /dev/null and b/tools/demobench/libs/macosx/x86/libpty.dylib differ
diff --git a/tools/demobench/libs/macosx/x86_64/libpty.dylib b/tools/demobench/libs/macosx/x86_64/libpty.dylib
new file mode 100755
index 0000000000..1093ba1c84
Binary files /dev/null and b/tools/demobench/libs/macosx/x86_64/libpty.dylib differ
diff --git a/tools/demobench/libs/pty4j-0.7.2.jar b/tools/demobench/libs/pty4j-0.7.2.jar
new file mode 100644
index 0000000000..e336572696
Binary files /dev/null and b/tools/demobench/libs/pty4j-0.7.2.jar differ
diff --git a/tools/demobench/libs/win/x86/libwinpty.dll b/tools/demobench/libs/win/x86/libwinpty.dll
new file mode 100644
index 0000000000..e1644b8ee8
Binary files /dev/null and b/tools/demobench/libs/win/x86/libwinpty.dll differ
diff --git a/tools/demobench/libs/win/x86/winpty-agent.exe b/tools/demobench/libs/win/x86/winpty-agent.exe
new file mode 100644
index 0000000000..3abad80b84
Binary files /dev/null and b/tools/demobench/libs/win/x86/winpty-agent.exe differ
diff --git a/tools/demobench/libs/win/x86/winpty.dll b/tools/demobench/libs/win/x86/winpty.dll
new file mode 100644
index 0000000000..f07b95a27b
Binary files /dev/null and b/tools/demobench/libs/win/x86/winpty.dll differ
diff --git a/tools/demobench/libs/win/x86_64/cyglaunch.exe b/tools/demobench/libs/win/x86_64/cyglaunch.exe
new file mode 100644
index 0000000000..d84e6e964c
Binary files /dev/null and b/tools/demobench/libs/win/x86_64/cyglaunch.exe differ
diff --git a/tools/demobench/libs/win/x86_64/winpty-agent.exe b/tools/demobench/libs/win/x86_64/winpty-agent.exe
new file mode 100644
index 0000000000..e1963c4e3c
Binary files /dev/null and b/tools/demobench/libs/win/x86_64/winpty-agent.exe differ
diff --git a/tools/demobench/libs/win/x86_64/winpty.dll b/tools/demobench/libs/win/x86_64/winpty.dll
new file mode 100644
index 0000000000..f9bdec0394
Binary files /dev/null and b/tools/demobench/libs/win/x86_64/winpty.dll differ
diff --git a/tools/demobench/libs/win/xp/winpty-agent.exe b/tools/demobench/libs/win/xp/winpty-agent.exe
new file mode 100644
index 0000000000..cc18efda8c
Binary files /dev/null and b/tools/demobench/libs/win/xp/winpty-agent.exe differ
diff --git a/tools/demobench/libs/win/xp/winpty.dll b/tools/demobench/libs/win/xp/winpty.dll
new file mode 100644
index 0000000000..bd40cd1e9d
Binary files /dev/null and b/tools/demobench/libs/win/xp/winpty.dll differ
diff --git a/tools/demobench/package-demobench-dmg.sh b/tools/demobench/package-demobench-dmg.sh
new file mode 100755
index 0000000000..c72c6d3abe
--- /dev/null
+++ b/tools/demobench/package-demobench-dmg.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+DIRNAME=$(dirname $0)
+
+if [ -z "$JAVA_HOME" -o ! -x $JAVA_HOME/bin/java ]; then
+ echo "Please set JAVA_HOME correctly"
+ exit 1
+fi
+
+$DIRNAME/../../gradlew -PpackageType=dmg javapackage $*
+echo
+echo "Wrote installer to '$(find build/javapackage/bundles -type f)'"
+echo
diff --git a/tools/demobench/package-demobench-exe.bat b/tools/demobench/package-demobench-exe.bat
new file mode 100644
index 0000000000..bb6c21191d
--- /dev/null
+++ b/tools/demobench/package-demobench-exe.bat
@@ -0,0 +1,20 @@
+@echo off
+
+@rem Creates an EXE installer for DemoBench.
+@rem Assumes that Inno Setup 5+ has already been installed (http://www.jrsoftware.org/isinfo.php)
+
+if not defined JAVA_HOME goto NoJavaHome
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+
+call %DIRNAME%\..\..\gradlew -PpackageType=exe javapackage
+@echo
+@echo "Wrote installer to %DIRNAME%\build\javapackage\bundles\"
+@echo
+goto end
+
+:NoJavaHome
+@echo "Please set JAVA_HOME correctly"
+
+:end
diff --git a/tools/demobench/package-demobench-rpm.sh b/tools/demobench/package-demobench-rpm.sh
new file mode 100755
index 0000000000..3d14661206
--- /dev/null
+++ b/tools/demobench/package-demobench-rpm.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+DIRNAME=$(dirname $0)
+
+if [ -z "$JAVA_HOME" -o ! -x $JAVA_HOME/bin/java ]; then
+ echo "Please set JAVA_HOME correctly"
+ exit 1
+fi
+
+$DIRNAME/../../gradlew -PpackageType=rpm javapackage $*
+echo
+echo "Wrote installer to '$(find $DIRNAME/build/javapackage/bundles -type f)'"
+echo
diff --git a/tools/demobench/package/linux/DemoBench.png b/tools/demobench/package/linux/DemoBench.png
new file mode 100644
index 0000000000..d72d6ad543
Binary files /dev/null and b/tools/demobench/package/linux/DemoBench.png differ
diff --git a/tools/demobench/package/linux/DemoBench.spec b/tools/demobench/package/linux/DemoBench.spec
new file mode 100644
index 0000000000..6162007b2e
--- /dev/null
+++ b/tools/demobench/package/linux/DemoBench.spec
@@ -0,0 +1,70 @@
+Summary: DemoBench
+Name: demobench
+Version: @pkg_version@
+Release: 1
+License: Unknown
+Vendor: Unknown
+Prefix: /opt
+Provides: demobench
+Requires: ld-linux.so.2 libX11.so.6 libXext.so.6 libXi.so.6 libXrender.so.1 libXtst.so.6 libasound.so.2 libc.so.6 libdl.so.2 libgcc_s.so.1 libm.so.6 libpthread.so.0 libthread_db.so.1
+Autoprov: 0
+Autoreq: 0
+
+#avoid ARCH subfolder
+%define _rpmfilename %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm
+
+#comment line below to enable effective jar compression
+#it could easily get your package size from 40 to 15Mb but
+#build time will substantially increase and it may require unpack200/system java to install
+%define __jar_repack %{nil}
+
+%define _javaHome %{getenv:JAVA_HOME}
+
+%description
+DemoBench
+
+%prep
+
+%build
+
+%install
+rm -rf %{buildroot}
+mkdir -p %{buildroot}/opt
+cp -r %{_sourcedir}/DemoBench %{buildroot}/opt
+mkdir -p %{buildroot}/opt/DemoBench/runtime/bin
+cp %{_javaHome}/bin/java %{buildroot}/opt/DemoBench/runtime/bin
+
+%files
+
+/opt/DemoBench
+
+%post
+
+
+xdg-desktop-menu install --novendor /opt/DemoBench/DemoBench.desktop
+
+if [ "false" = "true" ]; then
+ cp /opt/DemoBench/demobench.init /etc/init.d/demobench
+ if [ -x "/etc/init.d/demobench" ]; then
+ /sbin/chkconfig --add demobench
+ if [ "false" = "true" ]; then
+ /etc/init.d/demobench start
+ fi
+ fi
+fi
+
+%preun
+
+xdg-desktop-menu uninstall --novendor /opt/DemoBench/DemoBench.desktop
+
+if [ "false" = "true" ]; then
+ if [ -x "/etc/init.d/demobench" ]; then
+ if [ "true" = "true" ]; then
+ /etc/init.d/demobench stop
+ fi
+ /sbin/chkconfig --del demobench
+ rm -f /etc/init.d/demobench
+ fi
+fi
+
+%clean
diff --git a/tools/demobench/package/macosx/DemoBench-post-image.sh b/tools/demobench/package/macosx/DemoBench-post-image.sh
new file mode 100644
index 0000000000..00c80b5ac7
--- /dev/null
+++ b/tools/demobench/package/macosx/DemoBench-post-image.sh
@@ -0,0 +1,33 @@
+if [ -z "$JAVA_HOME" ]; then
+ echo "**** Please set JAVA_HOME correctly."
+ exit 1
+fi
+
+function signApplication() {
+ APPDIR=$1
+ IDENTITY=$2
+
+ # Resign the embedded JRE because we have included "bin/java"
+ # after javapackager had already signed the JRE installation.
+ if ! (codesign --force --sign "$IDENTITY" --verbose $APPDIR/Contents/PlugIns/Java.runtime); then
+ echo "**** Failed to resign the embedded JVM"
+ return 1
+ fi
+}
+
+# Switch to folder containing application.
+cd ../images/image-*/DemoBench.app
+
+INSTALL_HOME=Contents/PlugIns/Java.runtime/Contents/Home/jre/bin
+if (mkdir -p $INSTALL_HOME); then
+ cp $JAVA_HOME/bin/java $INSTALL_HOME
+fi
+
+# Switch to image directory in order to sign it.
+cd ..
+
+# Sign the application using a 'Developer ID Application' key on our keychain.
+if ! (signApplication DemoBench.app "Developer ID Application: "); then
+ echo "**** Failed to sign the application - ABORT SIGNING"
+fi
+
diff --git a/tools/demobench/package/macosx/DemoBench-volume.icns b/tools/demobench/package/macosx/DemoBench-volume.icns
new file mode 100644
index 0000000000..447d28df81
Binary files /dev/null and b/tools/demobench/package/macosx/DemoBench-volume.icns differ
diff --git a/tools/demobench/package/macosx/DemoBench.icns b/tools/demobench/package/macosx/DemoBench.icns
new file mode 100644
index 0000000000..447d28df81
Binary files /dev/null and b/tools/demobench/package/macosx/DemoBench.icns differ
diff --git a/tools/demobench/package/windows/DemoBench-INVALID-setup-icon.bmp b/tools/demobench/package/windows/DemoBench-INVALID-setup-icon.bmp
new file mode 100644
index 0000000000..7402e28224
Binary files /dev/null and b/tools/demobench/package/windows/DemoBench-INVALID-setup-icon.bmp differ
diff --git a/tools/demobench/package/windows/DemoBench-post-image.wsf b/tools/demobench/package/windows/DemoBench-post-image.wsf
new file mode 100644
index 0000000000..b436c06501
--- /dev/null
+++ b/tools/demobench/package/windows/DemoBench-post-image.wsf
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/tools/demobench/package/windows/DemoBench.exe.manifest b/tools/demobench/package/windows/DemoBench.exe.manifest
new file mode 100644
index 0000000000..d632713b5e
--- /dev/null
+++ b/tools/demobench/package/windows/DemoBench.exe.manifest
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
diff --git a/tools/demobench/package/windows/DemoBench.ico b/tools/demobench/package/windows/DemoBench.ico
new file mode 100644
index 0000000000..cf9590ceae
Binary files /dev/null and b/tools/demobench/package/windows/DemoBench.ico differ
diff --git a/tools/demobench/src/main/java/net/corda/demobench/config/LoggingConfig.java b/tools/demobench/src/main/java/net/corda/demobench/config/LoggingConfig.java
new file mode 100644
index 0000000000..44e4c4b251
--- /dev/null
+++ b/tools/demobench/src/main/java/net/corda/demobench/config/LoggingConfig.java
@@ -0,0 +1,34 @@
+package net.corda.demobench.config;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.logging.LogManager;
+
+/**
+ * Configuration class for JUL / TornadoFX.
+ * Requires -Djava.util.logging.config.class=net.corda.demobench.config.LoggingConfig
+ * to be added to the JVM's command line.
+ */
+public class LoggingConfig {
+
+ public LoggingConfig() throws IOException {
+ try (InputStream input = getLoggingProperties()) {
+ LogManager manager = LogManager.getLogManager();
+ manager.readConfiguration(input);
+ }
+ }
+
+ private static InputStream getLoggingProperties() throws IOException {
+ ClassLoader classLoader = LoggingConfig.class.getClassLoader();
+ InputStream input = classLoader.getResourceAsStream("logging.properties");
+ if (input == null) {
+ Path javaHome = Paths.get(System.getProperty("java.home"));
+ input = Files.newInputStream(javaHome.resolve("lib").resolve("logging.properties"));
+ }
+ return input;
+ }
+
+}
diff --git a/tools/demobench/src/main/java/net/corda/demobench/pty/PtyProcessTtyConnector.java b/tools/demobench/src/main/java/net/corda/demobench/pty/PtyProcessTtyConnector.java
new file mode 100644
index 0000000000..3648f21480
--- /dev/null
+++ b/tools/demobench/src/main/java/net/corda/demobench/pty/PtyProcessTtyConnector.java
@@ -0,0 +1,42 @@
+package net.corda.demobench.pty;
+
+import com.jediterm.terminal.ProcessTtyConnector;
+import com.pty4j.PtyProcess;
+import com.pty4j.WinSize;
+
+import java.nio.charset.Charset;
+
+/**
+ * Copied from JediTerm pty.
+ * JediTerm is not available in any Maven repository.
+ * @author traff
+ */
+public class PtyProcessTtyConnector extends ProcessTtyConnector {
+ private final PtyProcess myProcess;
+ private final String name;
+
+ PtyProcessTtyConnector(String name, PtyProcess process, Charset charset) {
+ super(process, charset);
+ myProcess = process;
+ this.name = name;
+ }
+
+ @Override
+ protected void resizeImmediately() {
+ if (getPendingTermSize() != null && getPendingPixelSize() != null) {
+ myProcess.setWinSize(
+ new WinSize(getPendingTermSize().width, getPendingTermSize().height, getPendingPixelSize().width, getPendingPixelSize().height));
+ }
+ }
+
+ @Override
+ public boolean isConnected() {
+ return myProcess.isRunning();
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/DemoBench.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/DemoBench.kt
new file mode 100644
index 0000000000..b9447be9da
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/DemoBench.kt
@@ -0,0 +1,58 @@
+package net.corda.demobench
+
+import javafx.scene.image.Image
+import net.corda.demobench.views.DemoBenchView
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import tornadofx.App
+import tornadofx.addStageIcon
+
+/**
+ * README!
+ *
+ *
+ * This tool is intended to become a sales and educational tool for Corda. It is a standalone desktop app that
+ * comes bundled with an appropriate JVM, and which runs nodes in a local network. It has the following features:
+ *
+ * - New nodes can be added at the click of a button. Clicking "Add node" creates new tab that lets you edit the
+ * most important configuration properties of the node before launch, like the name and what apps will be loaded.
+ *
+ * - Each tab contains a terminal emulator, attached to the pty of the node. This lets you see console output and
+ * (soon) interact with the command shell of the node. See the mike-crshell branch in github.
+ *
+ * - An Explorer instance for the node can be launched at the click of a button. Credentials are handed to the
+ * Explorer so it starts out logged in already.
+ *
+ * - Some basic statistics are shown about each node, informed via the RPC connection.
+ *
+ * - Another button launches a database viewer (like the H2 web site) for the node. For instance, in an embedded
+ * WebView, or the system browser.
+ *
+ * - It can also run a Jetty instance that can load WARs that come with the bundled CorDapps (eventually).
+ *
+ * The app is nicely themed using the Corda branding. It is easy enough to use for non-developers to successfully
+ * demonstrate some example cordapps and why people should get excited about the platform. There is no setup
+ * overhead as everything is included: just double click the icon and start going through the script. There are no
+ * dependencies on external servers or network connections, so flaky conference room wifi should not be an issue.
+ */
+
+class DemoBench : App(DemoBenchView::class) {
+
+ /*
+ * This entry point is needed by JavaPackager, as
+ * otherwise the packaged application cannot run.
+ */
+ companion object {
+ @JvmStatic
+ fun main(args: Array) = launch(DemoBench::class.java, *args)
+ }
+
+ init {
+ addStageIcon(Image("cordalogo.png"))
+ }
+}
+
+/*
+ * Trivial utility function to create SLF4J Logger.
+ */
+inline fun loggerFor(): Logger = LoggerFactory.getLogger(T::class.java)
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt
new file mode 100644
index 0000000000..8ff79e1a81
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/Explorer.kt
@@ -0,0 +1,71 @@
+package net.corda.demobench.explorer
+
+import java.io.IOException
+import java.util.concurrent.Executors
+import net.corda.demobench.loggerFor
+import net.corda.demobench.model.NodeConfig
+import net.corda.demobench.model.forceDirectory
+
+class Explorer internal constructor(private val explorerController: ExplorerController) : AutoCloseable {
+ private companion object {
+ val log = loggerFor()
+ }
+
+ private val executor = Executors.newSingleThreadExecutor()
+ private var process: Process? = null
+
+ @Throws(IOException::class)
+ fun open(config: NodeConfig, onExit: (NodeConfig) -> Unit) {
+ val explorerDir = config.explorerDir.toFile()
+
+ if (!explorerDir.forceDirectory()) {
+ log.warn("Failed to create working directory '{}'", explorerDir.absolutePath)
+ onExit(config)
+ return
+ }
+
+ try {
+ val p = explorerController.process(
+ "--host=localhost",
+ "--port=${config.rpcPort}",
+ "--username=${config.users[0].user}",
+ "--password=${config.users[0].password}")
+ .directory(explorerDir)
+ .start()
+ process = p
+
+ log.info("Launched Node Explorer for '{}'", config.legalName)
+
+ // Close these streams because no-one is using them.
+ safeClose(p.outputStream)
+ safeClose(p.inputStream)
+ safeClose(p.errorStream)
+
+ executor.submit {
+ val exitValue = p.waitFor()
+ process = null
+
+ log.info("Node Explorer for '{}' has exited (value={})", config.legalName, exitValue)
+ onExit(config)
+ }
+ } catch (e: IOException) {
+ log.error("Failed to launch Node Explorer for '{}': {}", config.legalName, e.message)
+ onExit(config)
+ throw e
+ }
+ }
+
+ override fun close() {
+ executor.shutdown()
+ process?.destroy()
+ }
+
+ private fun safeClose(c: AutoCloseable) {
+ try {
+ c.close()
+ } catch (e: Exception) {
+ log.error("Failed to close stream: '{}'", e.message)
+ }
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt
new file mode 100644
index 0000000000..32813e627b
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/explorer/ExplorerController.kt
@@ -0,0 +1,19 @@
+package net.corda.demobench.explorer
+
+import net.corda.demobench.model.JVMConfig
+import tornadofx.Controller
+
+class ExplorerController : Controller() {
+
+ private val jvm by inject()
+ private val explorerPath = jvm.applicationDir.resolve("explorer").resolve("node-explorer.jar")
+
+ init {
+ log.info("Explorer JAR: $explorerPath")
+ }
+
+ internal fun process(vararg args: String) = jvm.processFor(explorerPath, *args)
+
+ fun explorer() = Explorer(this)
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt
new file mode 100644
index 0000000000..52a388b7a7
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/HasPlugins.kt
@@ -0,0 +1,7 @@
+package net.corda.demobench.model
+
+import java.nio.file.Path
+
+interface HasPlugins {
+ val pluginDir: Path
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt
new file mode 100644
index 0000000000..76aed4ae8c
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/InstallFactory.kt
@@ -0,0 +1,74 @@
+package net.corda.demobench.model
+
+import com.google.common.net.HostAndPort
+import com.typesafe.config.Config
+import java.io.IOException
+import java.nio.file.*
+import tornadofx.Controller
+
+class InstallFactory : Controller() {
+
+ private val nodeController by inject()
+ private val serviceController by inject()
+
+ @Throws(IOException::class)
+ fun toInstallConfig(config: Config, baseDir: Path): InstallConfig {
+ val p2pPort = config.parsePort("p2pAddress")
+ val rpcPort = config.parsePort("rpcAddress")
+ val webPort = config.parsePort("webAddress")
+ val h2Port = config.getInt("h2port")
+ val extraServices = config.parseExtraServices("extraAdvertisedServiceIds")
+ val tempDir = Files.createTempDirectory(baseDir, ".node")
+
+ val nodeConfig = NodeConfig(
+ tempDir,
+ config.getString("myLegalName"),
+ p2pPort,
+ rpcPort,
+ config.getString("nearestCity"),
+ webPort,
+ h2Port,
+ extraServices,
+ config.getObjectList("rpcUsers").map { toUser(it.unwrapped()) }.toList()
+ )
+
+ if (config.hasPath("networkMapService")) {
+ val nmap = config.getConfig("networkMapService")
+ nodeConfig.networkMap = NetworkMapConfig(nmap.getString("legalName"), nmap.parsePort("address"))
+ } else {
+ log.info("Node '${nodeConfig.legalName}' is the network map")
+ }
+
+ return InstallConfig(tempDir, nodeConfig)
+ }
+
+ private fun Config.parsePort(path: String): Int {
+ val address = this.getString(path)
+ val port = HostAndPort.fromString(address).port
+ require(nodeController.isPortValid(port), { "Invalid port $port from '$path'." })
+ return port
+ }
+
+ private fun Config.parseExtraServices(path: String): List {
+ val services = serviceController.services.toSortedSet()
+ return this.getStringList(path)
+ .filter { !it.isNullOrEmpty() }
+ .map { svc ->
+ require(svc in services, { "Unknown service '$svc'." } )
+ svc
+ }.toList()
+ }
+
+}
+
+/**
+ * Wraps the configuration information for a Node
+ * which isn't ready to be instantiated yet.
+ */
+class InstallConfig internal constructor(val baseDir: Path, private val config: NodeConfig) : HasPlugins {
+ val key = config.key
+ override val pluginDir: Path = baseDir.resolve("plugins")
+
+ fun deleteBaseDir(): Boolean = baseDir.toFile().deleteRecursively()
+ fun installTo(installDir: Path) = config.moveTo(installDir)
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt
new file mode 100644
index 0000000000..023f5cd1e7
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/JVMConfig.kt
@@ -0,0 +1,26 @@
+package net.corda.demobench.model
+
+import java.nio.file.Path
+import java.nio.file.Paths
+import tornadofx.Controller
+
+class JVMConfig : Controller() {
+
+ val userHome: Path = Paths.get(System.getProperty("user.home")).toAbsolutePath()
+ val dataHome: Path = userHome.resolve("demobench")
+ val javaPath: Path = Paths.get(System.getProperty("java.home"), "bin", "java")
+ val applicationDir: Path = Paths.get(System.getProperty("user.dir")).toAbsolutePath()
+
+ init {
+ log.info("Java executable: $javaPath")
+ }
+
+ fun commandFor(jarPath: Path, vararg args: String): List {
+ return listOf(javaPath.toString(), "-jar", jarPath.toString(), *args)
+ }
+
+ fun processFor(jarPath: Path, vararg args: String): ProcessBuilder {
+ return ProcessBuilder(commandFor(jarPath, *args))
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt
new file mode 100644
index 0000000000..cdfdf77522
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NetworkMapConfig.kt
@@ -0,0 +1,12 @@
+package net.corda.demobench.model
+
+open class NetworkMapConfig(val legalName: String, val p2pPort: Int) {
+
+ val key: String = legalName.toKey()
+
+}
+
+private val WHITESPACE = "\\s++".toRegex()
+
+fun String.stripWhitespace() = this.replace(WHITESPACE, "")
+fun String.toKey() = this.stripWhitespace().toLowerCase()
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt
new file mode 100644
index 0000000000..2a63f9ae0a
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeConfig.kt
@@ -0,0 +1,83 @@
+package net.corda.demobench.model
+
+import com.typesafe.config.*
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+
+class NodeConfig(
+ baseDir: Path,
+ legalName: String,
+ p2pPort: Int,
+ val rpcPort: Int,
+ val nearestCity: String,
+ val webPort: Int,
+ val h2Port: Int,
+ val extraServices: List,
+ val users: List = listOf(defaultUser),
+ var networkMap: NetworkMapConfig? = null
+) : NetworkMapConfig(legalName, p2pPort), HasPlugins {
+
+ companion object {
+ val renderOptions: ConfigRenderOptions = ConfigRenderOptions.defaults().setOriginComments(false)
+ val defaultUser = user("guest")
+ }
+
+ val nodeDir: Path = baseDir.resolve(key)
+ override val pluginDir: Path = nodeDir.resolve("plugins")
+ val explorerDir: Path = baseDir.resolve("$key-explorer")
+
+ var state: NodeState = NodeState.STARTING
+
+ val isCashIssuer: Boolean = extraServices.any {
+ it.startsWith("corda.issuer.")
+ }
+
+ fun isNetworkMap(): Boolean = networkMap == null
+
+ /*
+ * The configuration object depends upon the networkMap,
+ * which is mutable.
+ */
+ fun toFileConfig(): Config = ConfigFactory.empty()
+ .withValue("myLegalName", valueFor(legalName))
+ .withValue("p2pAddress", addressValueFor(p2pPort))
+ .withValue("nearestCity", valueFor(nearestCity))
+ .withValue("extraAdvertisedServiceIds", valueFor(extraServices))
+ .withFallback(optional("networkMapService", networkMap, {
+ c, n -> c.withValue("address", addressValueFor(n.p2pPort))
+ .withValue("legalName", valueFor(n.legalName))
+ } ))
+ .withValue("webAddress", addressValueFor(webPort))
+ .withValue("rpcAddress", addressValueFor(rpcPort))
+ .withValue("rpcUsers", valueFor(users.map(User::toMap).toList()))
+ .withValue("h2port", valueFor(h2Port))
+ .withValue("useTestClock", valueFor(true))
+
+ fun toText(): String = toFileConfig().root().render(renderOptions)
+
+ fun moveTo(baseDir: Path) = NodeConfig(
+ baseDir, legalName, p2pPort, rpcPort, nearestCity, webPort, h2Port, extraServices, users, networkMap
+ )
+
+ fun install(plugins: Collection) {
+ if (plugins.isNotEmpty() && pluginDir.toFile().forceDirectory()) {
+ plugins.forEach {
+ Files.copy(it, pluginDir.resolve(it.fileName.toString()), StandardCopyOption.REPLACE_EXISTING)
+ }
+ }
+ }
+
+}
+
+private fun valueFor(any: T): ConfigValue? = ConfigValueFactory.fromAnyRef(any)
+
+private fun addressValueFor(port: Int) = valueFor("localhost:$port")
+
+private inline fun optional(path: String, obj: T?, body: (Config, T) -> Config): Config {
+ val config = ConfigFactory.empty()
+ return if (obj == null) config else body(config, obj).atPath(path)
+}
+
+fun File.forceDirectory(): Boolean = this.isDirectory || this.mkdirs()
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt
new file mode 100644
index 0000000000..de33d473e4
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeController.kt
@@ -0,0 +1,189 @@
+package net.corda.demobench.model
+
+import java.io.IOException
+import java.lang.management.ManagementFactory
+import java.net.ServerSocket
+import java.nio.file.Files
+import java.nio.file.Path
+import java.text.SimpleDateFormat
+import java.util.*
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.logging.Level
+import net.corda.demobench.plugin.PluginController
+import net.corda.demobench.pty.R3Pty
+import tornadofx.Controller
+
+class NodeController : Controller() {
+ companion object {
+ const val firstPort = 10000
+ const val minPort = 1024
+ const val maxPort = 65535
+ }
+
+ private val jvm by inject()
+ private val pluginController by inject()
+
+ private var baseDir: Path = baseDirFor(ManagementFactory.getRuntimeMXBean().startTime)
+ private val cordaPath: Path = jvm.applicationDir.resolve("corda").resolve("corda.jar")
+ private val command = jvm.commandFor(cordaPath).toTypedArray()
+
+ private val nodes = LinkedHashMap()
+ private val port = AtomicInteger(firstPort)
+
+ private var networkMapConfig: NetworkMapConfig? = null
+
+ val activeNodes: List get() = nodes.values.filter {
+ (it.state == NodeState.RUNNING) || (it.state == NodeState.STARTING)
+ }
+
+ init {
+ log.info("Base directory: $baseDir")
+ log.info("Corda JAR: $cordaPath")
+ }
+
+ /**
+ * Validate a Node configuration provided by [net.corda.demobench.views.NodeTabView].
+ */
+ fun validate(nodeData: NodeData): NodeConfig? {
+ val config = NodeConfig(
+ baseDir,
+ nodeData.legalName.value.trim(),
+ nodeData.p2pPort.value,
+ nodeData.rpcPort.value,
+ nodeData.nearestCity.value.trim(),
+ nodeData.webPort.value,
+ nodeData.h2Port.value,
+ nodeData.extraServices.value
+ )
+
+ if (nodes.putIfAbsent(config.key, config) != null) {
+ log.warning("Node with key '${config.key}' already exists.")
+ return null
+ }
+
+ // The first node becomes our network map
+ chooseNetworkMap(config)
+
+ return config
+ }
+
+ fun dispose(config: NodeConfig) {
+ config.state = NodeState.DEAD
+
+ if (config.networkMap == null) {
+ log.warning("Network map service (Node '${config.legalName}') has exited.")
+ }
+ }
+
+ val nextPort: Int get() = port.andIncrement
+
+ fun isPortAvailable(port: Int): Boolean {
+ if (isPortValid(port)) {
+ try {
+ ServerSocket(port).close()
+ return true
+ } catch (e: IOException) {
+ return false
+ }
+ } else {
+ return false
+ }
+ }
+
+ fun isPortValid(port: Int) = (port >= minPort) && (port <= maxPort)
+
+ fun keyExists(key: String) = nodes.keys.contains(key)
+
+ fun nameExists(name: String) = keyExists(name.toKey())
+
+ fun hasNetworkMap(): Boolean = networkMapConfig != null
+
+ private fun chooseNetworkMap(config: NodeConfig) {
+ if (hasNetworkMap()) {
+ config.networkMap = networkMapConfig
+ } else {
+ networkMapConfig = config
+ log.info("Network map provided by: ${config.legalName}")
+ }
+ }
+
+ fun runCorda(pty: R3Pty, config: NodeConfig): Boolean {
+ val nodeDir = config.nodeDir.toFile()
+
+ if (nodeDir.forceDirectory()) {
+ try {
+ // Install any built-in plugins into the working directory.
+ pluginController.populate(config)
+
+ // Write this node's configuration file into its working directory.
+ val confFile = nodeDir.resolve("node.conf")
+ confFile.writeText(config.toText())
+
+ // Execute the Corda node
+ pty.run(command, System.getenv(), nodeDir.toString())
+ log.info("Launched node: ${config.legalName}")
+ return true
+ } catch (e: Exception) {
+ log.log(Level.SEVERE, "Failed to launch Corda: ${e.message}", e)
+ return false
+ }
+ } else {
+ return false
+ }
+ }
+
+ fun reset() {
+ baseDir = baseDirFor(System.currentTimeMillis())
+ log.info("Changed base directory: $baseDir")
+
+ // Wipe out any knowledge of previous nodes.
+ networkMapConfig = null
+ nodes.clear()
+ }
+
+ /**
+ * Add a [NodeConfig] object that has been loaded from a profile.
+ */
+ fun register(config: NodeConfig): Boolean {
+ if (nodes.putIfAbsent(config.key, config) != null) {
+ return false
+ }
+
+ updatePort(config)
+
+ if ((networkMapConfig == null) && config.isNetworkMap()) {
+ networkMapConfig = config
+ }
+
+ return true
+ }
+
+ /**
+ * Creates a node directory that can host a running instance of Corda.
+ */
+ @Throws(IOException::class)
+ fun install(config: InstallConfig): NodeConfig {
+ val installed = config.installTo(baseDir)
+
+ pluginController.userPluginsFor(config).forEach {
+ val pluginDir = Files.createDirectories(installed.pluginDir)
+ val plugin = Files.copy(it, pluginDir.resolve(it.fileName.toString()))
+ log.info("Installed: $plugin")
+ }
+
+ if (!config.deleteBaseDir()) {
+ log.warning("Failed to remove '${config.baseDir}'")
+ }
+
+ return installed
+ }
+
+ private fun updatePort(config: NodeConfig) {
+ val nextPort = 1 + arrayOf(config.p2pPort, config.rpcPort, config.webPort, config.h2Port).max() as Int
+ port.getAndUpdate { Math.max(nextPort, it) }
+ }
+
+ private fun baseDirFor(time: Long): Path = jvm.dataHome.resolve(localFor(time))
+ private fun localFor(time: Long) = SimpleDateFormat("yyyyMMddHHmmss").format(Date(time))
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeData.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeData.kt
new file mode 100644
index 0000000000..c69e9c54fa
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeData.kt
@@ -0,0 +1,18 @@
+package net.corda.demobench.model
+
+import tornadofx.observable
+import javafx.beans.property.SimpleIntegerProperty
+import javafx.beans.property.SimpleListProperty
+import javafx.beans.property.SimpleStringProperty
+
+class NodeData {
+
+ val legalName = SimpleStringProperty("")
+ val nearestCity = SimpleStringProperty("London")
+ val p2pPort = SimpleIntegerProperty()
+ val rpcPort = SimpleIntegerProperty()
+ val webPort = SimpleIntegerProperty()
+ val h2Port = SimpleIntegerProperty()
+ val extraServices = SimpleListProperty(mutableListOf().observable())
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeDataModel.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeDataModel.kt
new file mode 100644
index 0000000000..d6b5165b5f
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeDataModel.kt
@@ -0,0 +1,14 @@
+package net.corda.demobench.model
+
+import tornadofx.ItemViewModel
+
+class NodeDataModel : ItemViewModel(NodeData()) {
+
+ val legalName = bind { item?.legalName }
+ val nearestCity = bind { item?.nearestCity }
+ val p2pPort = bind { item?.p2pPort }
+ val rpcPort = bind { item?.rpcPort }
+ val webPort = bind { item?.webPort }
+ val h2Port = bind { item?.h2Port }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeState.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeState.kt
new file mode 100644
index 0000000000..3147fa7d27
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/NodeState.kt
@@ -0,0 +1,7 @@
+package net.corda.demobench.model
+
+enum class NodeState {
+ STARTING,
+ RUNNING,
+ DEAD
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt
new file mode 100644
index 0000000000..62c4ee76ed
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/ServiceController.kt
@@ -0,0 +1,41 @@
+package net.corda.demobench.model
+
+import java.io.IOException
+import java.io.InputStreamReader
+import java.net.URL
+import java.util.*
+import java.util.logging.Level
+import tornadofx.Controller
+
+class ServiceController(resourceName: String = "/services.conf") : Controller() {
+
+ val services: List = loadConf(resources.url(resourceName))
+
+ val notaries: List = services.filter { it.startsWith("corda.notary.") }.toList()
+
+ /*
+ * Load our list of known extra Corda services.
+ */
+ private fun loadConf(url: URL?): List {
+ if (url == null) {
+ return emptyList()
+ } else {
+ try {
+ val set = TreeSet()
+ InputStreamReader(url.openStream()).useLines { sq ->
+ sq.forEach { line ->
+ val service = line.trim()
+ set.add(service)
+
+ log.info("Supports: $service")
+ }
+ }
+ return set.toList()
+ } catch (e: IOException) {
+ log.log(Level.SEVERE, "Failed to load $url: ${e.message}", e)
+ return emptyList()
+ }
+ }
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt
new file mode 100644
index 0000000000..4d16f6dd9b
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/model/User.kt
@@ -0,0 +1,18 @@
+package net.corda.demobench.model
+
+data class User(val user: String, val password: String, val permissions: List) {
+ fun toMap() = mapOf(
+ "user" to user,
+ "password" to password,
+ "permissions" to permissions
+ )
+}
+
+@Suppress("UNCHECKED_CAST")
+fun toUser(map: Map) = User(
+ map.getOrElse("user", { "none" }) as String,
+ map.getOrElse("password", { "none" }) as String,
+ map.getOrElse("permissions", { emptyList() }) as List
+)
+
+fun user(name: String) = User(name, "letmein", listOf("ALL"))
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt
new file mode 100644
index 0000000000..6c69585f5a
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/plugin/PluginController.kt
@@ -0,0 +1,47 @@
+package net.corda.demobench.plugin
+
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.stream.Stream
+import net.corda.demobench.model.HasPlugins
+import net.corda.demobench.model.JVMConfig
+import net.corda.demobench.model.NodeConfig
+import tornadofx.*
+
+class PluginController : Controller() {
+
+ private val jvm by inject()
+ private val pluginDir: Path = jvm.applicationDir.resolve("plugins")
+ private val bankOfCorda = pluginDir.resolve("bank-of-corda.jar").toFile()
+
+ /**
+ * Install any built-in plugins that this node requires.
+ */
+ @Throws(IOException::class)
+ fun populate(config: NodeConfig) {
+ // Nodes cannot issue cash unless they contain the "Bank of Corda" plugin.
+ if (config.isCashIssuer && bankOfCorda.isFile) {
+ bankOfCorda.copyTo(config.pluginDir.resolve(bankOfCorda.name).toFile(), overwrite=true)
+ log.info("Installed 'Bank of Corda' plugin")
+ }
+ }
+
+ /**
+ * Generates a stream of a node's non-built-it plugins.
+ */
+ @Throws(IOException::class)
+ fun userPluginsFor(config: HasPlugins): Stream = walkPlugins(config.pluginDir)
+ .filter { bankOfCorda.name != it.fileName.toString() }
+
+ private fun walkPlugins(pluginDir: Path): Stream {
+ return if (Files.isDirectory(pluginDir))
+ Files.walk(pluginDir, 1).filter(Path::isPlugin)
+ else
+ Stream.empty()
+ }
+
+}
+
+fun Path.isPlugin(): Boolean = Files.isReadable(this) && this.fileName.toString().endsWith(".jar")
+fun Path.inPluginsDir(): Boolean = (this.parent != null) && this.parent.endsWith("plugins/")
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt
new file mode 100644
index 0000000000..0d32a2b396
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/profile/ProfileController.kt
@@ -0,0 +1,140 @@
+package net.corda.demobench.profile
+
+import com.typesafe.config.Config
+import com.typesafe.config.ConfigFactory
+import java.io.File
+import java.io.IOException
+import java.net.URI
+import java.nio.charset.StandardCharsets.UTF_8
+import java.nio.file.*
+import java.util.*
+import java.util.function.BiPredicate
+import java.util.logging.Level
+import java.util.stream.StreamSupport
+import javafx.stage.FileChooser
+import javafx.stage.FileChooser.ExtensionFilter
+import kotlinx.support.jdk8.collections.spliterator
+import net.corda.demobench.model.*
+import net.corda.demobench.plugin.PluginController
+import net.corda.demobench.plugin.inPluginsDir
+import net.corda.demobench.plugin.isPlugin
+import tornadofx.Controller
+
+class ProfileController : Controller() {
+
+ private val jvm by inject()
+ private val baseDir: Path = jvm.dataHome
+ private val nodeController by inject()
+ private val pluginController by inject()
+ private val installFactory by inject()
+ private val chooser = FileChooser()
+
+ init {
+ chooser.title = "DemoBench Profiles"
+ chooser.initialDirectory = baseDir.toFile()
+ chooser.extensionFilters.add(ExtensionFilter("DemoBench profiles (*.profile)", "*.profile", "*.PROFILE"))
+ }
+
+ /**
+ * Saves the active node configurations into a ZIP file, along with their Cordapps.
+ */
+ @Throws(IOException::class)
+ fun saveProfile(): Boolean {
+ val target = forceExtension(chooser.showSaveDialog(null) ?: return false, ".profile")
+ log.info("Saving profile as: $target")
+
+ val configs = nodeController.activeNodes
+
+ // Delete the profile, if it already exists. The save
+ // dialogue has already confirmed that this is OK.
+ target.delete()
+
+ // Write the profile as a ZIP file.
+ try {
+ FileSystems.newFileSystem(URI.create("jar:" + target.toURI()), mapOf("create" to "true")).use { fs ->
+ configs.forEach { config ->
+ // Write the configuration file.
+ val nodeDir = Files.createDirectories(fs.getPath(config.key))
+ val file = Files.write(nodeDir.resolve("node.conf"), config.toText().toByteArray(UTF_8))
+ log.info("Wrote: $file")
+
+ // Write all of the non-built-in plugins.
+ val pluginDir = Files.createDirectory(nodeDir.resolve("plugins"))
+ pluginController.userPluginsFor(config).forEach {
+ val plugin = Files.copy(it, pluginDir.resolve(it.fileName.toString()))
+ log.info("Wrote: $plugin")
+ }
+ }
+ }
+
+ log.info("Profile saved.")
+ } catch (e: IOException) {
+ log.log(Level.SEVERE, "Failed to save profile '$target': '${e.message}'", e)
+ target.delete()
+ throw e
+ }
+
+ return true
+ }
+
+ private fun forceExtension(target: File, ext: String): File {
+ return if (target.extension.isEmpty()) File(target.parent, target.name + ext) else target
+ }
+
+ /**
+ * Parses a profile (ZIP) file.
+ */
+ @Throws(IOException::class)
+ fun openProfile(): List? {
+ val chosen = chooser.showOpenDialog(null) ?: return null
+ log.info("Selected profile: $chosen")
+
+ val configs = LinkedList()
+
+ FileSystems.newFileSystem(chosen.toPath(), null).use { fs ->
+ // Identify the nodes first...
+ StreamSupport.stream(fs.rootDirectories.spliterator(), false)
+ .flatMap { Files.find(it, 2, BiPredicate { p, attr -> "node.conf" == p?.fileName.toString() && attr.isRegularFile }) }
+ .map { file ->
+ try {
+ val config = installFactory.toInstallConfig(parse(file), baseDir)
+ log.info("Loaded: $file")
+ config
+ } catch (e: Exception) {
+ log.log(Level.SEVERE, "Failed to parse '$file': ${e.message}", e)
+ throw e
+ }
+ // Java seems to "walk" through the ZIP file backwards.
+ // So add new config to the front of the list, so that
+ // our final list is ordered to match the file.
+ }.forEach { configs.addFirst(it) }
+
+ val nodeIndex = configs.map { it.key to it }.toMap()
+
+ // Now extract all of the plugins from the ZIP file,
+ // and copy them to a temporary location.
+ StreamSupport.stream(fs.rootDirectories.spliterator(), false)
+ .flatMap { Files.find(it, 3, BiPredicate { p, attr -> p.inPluginsDir() && p.isPlugin() && attr.isRegularFile }) }
+ .forEach { plugin ->
+ val config = nodeIndex[plugin.getName(0).toString()] ?: return@forEach
+
+ try {
+ val pluginDir = Files.createDirectories(config.pluginDir)
+ Files.copy(plugin, pluginDir.resolve(plugin.fileName.toString()))
+ log.info("Loaded: $plugin")
+ } catch (e: Exception) {
+ log.log(Level.SEVERE, "Failed to extract '$plugin': ${e.message}", e)
+ configs.forEach { c -> c.deleteBaseDir() }
+ throw e
+ }
+ }
+ }
+
+ return configs
+ }
+
+ private fun parse(path: Path): Config = Files.newBufferedReader(path).use {
+ return ConfigFactory.parseReader(it)
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt
new file mode 100644
index 0000000000..4dfa11579d
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/pty/R3Pty.kt
@@ -0,0 +1,64 @@
+package net.corda.demobench.pty
+
+import com.jediterm.terminal.TtyConnector
+import com.jediterm.terminal.ui.*
+import com.jediterm.terminal.ui.settings.SettingsProvider
+import com.pty4j.PtyProcess
+import net.corda.demobench.loggerFor
+
+import java.awt.*
+import java.io.IOException
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.*
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+class R3Pty(val name: String, settings: SettingsProvider, dimension: Dimension, val onExit: () -> Unit) : AutoCloseable {
+ private companion object {
+ val log = loggerFor()
+ }
+
+ private val executor = Executors.newSingleThreadExecutor()
+
+ val terminal = JediTermWidget(dimension, settings)
+
+ override fun close() {
+ log.info("Closing terminal '{}'", name)
+ executor.shutdown()
+ terminal.close()
+ }
+
+ private fun createTtyConnector(command: Array, environment: Map, workingDir: String?): TtyConnector {
+ val process = PtyProcess.exec(command, environment, workingDir)
+
+ try {
+ return PtyProcessTtyConnector(name, process, UTF_8)
+ } catch (e: Exception) {
+ process.destroyForcibly()
+ process.waitFor(30, TimeUnit.SECONDS)
+ throw e
+ }
+ }
+
+ @Throws(IOException::class)
+ fun run(args: Array, envs: Map, workingDir: String?) {
+ check(!terminal.isSessionRunning, { "${terminal.sessionName} is already running" })
+
+ val environment = HashMap(envs)
+ if (!UIUtil.isWindows) {
+ environment["TERM"] = "xterm"
+ }
+
+ val connector = createTtyConnector(args, environment, workingDir)
+
+ executor.submit {
+ val exitValue = connector.waitFor()
+ log.info("Terminal has exited (value={})", exitValue)
+ onExit()
+ }
+
+ val session = terminal.createTerminalSession(connector)
+ session.start()
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt
new file mode 100644
index 0000000000..6ea1355fe9
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/rpc/NodeRPC.kt
@@ -0,0 +1,55 @@
+package net.corda.demobench.rpc
+
+import com.google.common.net.HostAndPort
+import java.util.*
+import java.util.concurrent.TimeUnit.SECONDS
+import net.corda.core.messaging.CordaRPCOps
+import net.corda.demobench.loggerFor
+import net.corda.demobench.model.NodeConfig
+import net.corda.node.services.messaging.CordaRPCClient
+
+class NodeRPC(config: NodeConfig, start: () -> Unit, invoke: (CordaRPCOps) -> Unit): AutoCloseable {
+
+ private companion object {
+ val log = loggerFor()
+ val oneSecond = SECONDS.toMillis(1)
+ }
+
+ private val rpcClient = CordaRPCClient(HostAndPort.fromParts("localhost", config.rpcPort))
+ private val timer = Timer()
+
+ init {
+ val setupTask = object : TimerTask() {
+ override fun run() {
+ try {
+ rpcClient.start(config.users[0].user, config.users[0].password)
+ val ops = rpcClient.proxy()
+
+ // Cancel the "setup" task now that we've created the RPC client.
+ this.cancel()
+
+ // Run "start-up" task, now that the RPC client is ready.
+ start()
+
+ // Schedule a new task that will refresh the display once per second.
+ timer.schedule(object: TimerTask() {
+ override fun run() {
+ invoke(ops)
+ }
+ }, 0, oneSecond)
+ } catch (e: Exception) {
+ log.warn("Node '{}' not ready yet (Error: {})", config.legalName, e.message)
+ }
+ }
+ }
+
+ // Wait 5 seconds for the node to start, and then poll once per second.
+ timer.schedule(setupTask, 5 * oneSecond, oneSecond)
+ }
+
+ override fun close() {
+ timer.cancel()
+ rpcClient.close()
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/ui/CloseableTab.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/ui/CloseableTab.kt
new file mode 100644
index 0000000000..95c83f9a92
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/ui/CloseableTab.kt
@@ -0,0 +1,25 @@
+package net.corda.demobench.ui
+
+import com.sun.javafx.scene.control.behavior.TabPaneBehavior
+import javafx.scene.Node
+import javafx.scene.control.Tab
+import tornadofx.*
+
+/**
+ * Using reflection, which works on both JDK8 and JDK9.
+ * @see: JDK-8091261
+ */
+class CloseableTab(text: String, content: Node) : Tab(text, content) {
+
+ fun requestClose() {
+ val skin = tabPane?.skin ?: return
+ val field = skin.javaClass.findFieldByName("behavior") ?: return
+ field.isAccessible = true
+
+ val behaviour = field.get(skin) as? TabPaneBehavior ?: return
+ if (behaviour.canCloseTab(this)) {
+ behaviour.closeTab(this)
+ }
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/ui/PropertyLabel.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/ui/PropertyLabel.kt
new file mode 100644
index 0000000000..25cfb953ac
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/ui/PropertyLabel.kt
@@ -0,0 +1,31 @@
+package net.corda.demobench.ui
+
+import javafx.scene.control.Label
+import javafx.scene.layout.HBox
+
+class PropertyLabel() : HBox() {
+
+ val nameLabel = Label()
+ val valueLabel = Label()
+
+ var name : String
+ get() = nameLabel.text
+ set(value) {
+ nameLabel.text = value
+ }
+
+ var value: String
+ get() = valueLabel.text
+ set(value) {
+ valueLabel.text = value
+ }
+
+ init {
+ nameLabel.styleClass.add("property-name")
+ valueLabel.styleClass.add("property-value")
+
+ children.addAll(nameLabel, valueLabel)
+ styleClass.add("property-label")
+ }
+
+}
diff --git a/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt b/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt
new file mode 100644
index 0000000000..14d5970dff
--- /dev/null
+++ b/tools/demobench/src/main/kotlin/net/corda/demobench/views/DemoBenchView.kt
@@ -0,0 +1,124 @@
+package net.corda.demobench.views
+
+import java.util.*
+import javafx.application.Platform
+import javafx.scene.Parent
+import javafx.scene.control.Button
+import javafx.scene.control.MenuItem
+import javafx.scene.control.Tab
+import javafx.scene.control.TabPane
+import net.corda.demobench.model.InstallConfig
+import net.corda.demobench.model.NodeController
+import net.corda.demobench.profile.ProfileController
+import net.corda.demobench.ui.CloseableTab
+import org.controlsfx.dialog.ExceptionDialog
+import tornadofx.*
+
+class DemoBenchView : View("Corda Demo Bench") {
+
+ override val root by fxml()
+
+ private val profileController by inject()
+ private val nodeController by inject()
+ private val addNodeButton by fxid