JAL-4059 JAL-4395 Updated closure-compiler. Added local_eclipse.properties for buildship
[jalview.git] / build.gradle
index 43f03ee..df1f5cd 100644 (file)
@@ -10,6 +10,11 @@ import org.gradle.plugins.ide.eclipse.model.Output
 import org.gradle.plugins.ide.eclipse.model.Library
 import java.security.MessageDigest
 import java.util.regex.Matcher
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+import java.nio.file.Path
 import groovy.transform.ExternalizeMethods
 import groovy.util.XmlParser
 import groovy.xml.XmlUtil
@@ -48,9 +53,9 @@ plugins {
   id 'application'
   id 'eclipse'
   id "com.diffplug.gradle.spotless" version "3.28.0"
-  id 'com.github.johnrengelman.shadow' version '4.0.3'
-  id 'com.install4j.gradle' version '9.0.6'
-  id 'com.dorongold.task-tree' version '2.1.0' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
+  id 'com.github.johnrengelman.shadow' version '6.0.0'
+  id 'com.install4j.gradle' version '10.0.3'
+  id 'com.dorongold.task-tree' version '2.1.1' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
   id 'com.palantir.git-version' version '0.13.0' apply false
 }
 
@@ -67,39 +72,48 @@ def string(Object o) {
   return o == null ? "" : o.toString()
 }
 
-def overrideProperties(String propsFileName, boolean output = false) {
-  if (propsFileName == null) {
-    return
-  }
+def Properties readPropertiesFile(String propsFileName) {
+  def p = null
   def propsFile = file(propsFileName)
   if (propsFile != null && propsFile.exists()) {
     println("Using properties from file '${propsFileName}'")
     try {
-      def p = new Properties()
+      p = new Properties()
       def localPropsFIS = new FileInputStream(propsFile)
       p.load(localPropsFIS)
       localPropsFIS.close()
-      p.each {
-        key, val -> 
-          def oldval
-          if (project.hasProperty(key)) {
-            oldval = project.findProperty(key)
-            project.setProperty(key, val)
-            if (output) {
-              println("Overriding property '${key}' ('${oldval}') with ${file(propsFile).getName()} value '${val}'")
-            }
-          } else {
-            ext.setProperty(key, val)
-            if (output) {
-              println("Setting ext property '${key}' with ${file(propsFile).getName()}s value '${val}'")
-            }
-          }
-      }
     } catch (Exception e) {
-      println("Exception reading local.properties")
+      println("Exception reading properties file '${propsFileName}'")
       e.printStackTrace()
     }
   }
+  return p
+}
+
+def overrideProperties(String propsFileName, boolean output = false) {
+  if (propsFileName == null) {
+    return
+  }
+  def propsFile = file(propsFileName)
+  if (propsFile != null && propsFile.exists()) {
+    println("Using properties from file '${propsFileName}'")
+    def p = readPropertiesFile(propsFileName)
+    p.each { key, val -> 
+      def oldval
+      if (project.hasProperty(key)) {
+        oldval = project.findProperty(key)
+        project.setProperty(key, val)
+        if (output) {
+          println("Overriding property '${key}' ('${oldval}') with ${file(propsFile).getName()} value '${val}'")
+        }
+      } else {
+        ext.setProperty(key, val)
+        if (output) {
+          println("Setting ext property '${key}' with ${file(propsFile).getName()}s value '${val}'")
+        }
+      }
+    }
+  }
 }
 
 ext {
@@ -118,12 +132,14 @@ ext {
   channelDir = string("${jalviewDir}/${channel_properties_dir}/${channelDirName}")
   channelGradleProperties = string("${channelDir}/channel_gradle.properties")
   channelPropsFile = string("${channelDir}/${resource_dir}/${channel_props}")
+  localProperties = "local.properties"
+  localEclipseProperties = "local_eclipse.properties"
   overrideProperties(channelGradleProperties, false)
   // local build environment properties
   // can be "projectDir/local.properties"
-  overrideProperties("${projectDir}/local.properties", true)
+  overrideProperties("${projectDir}/${localProperties}", true)
   // or "../projectDir_local.properties"
-  overrideProperties(projectDir.getParent() + "/" + projectDir.getName() + "_local.properties", true)
+  overrideProperties(projectDir.getParent() + "/" + projectDir.getName() + "_${localProperties}", true)
 
   ////  
   // Import releaseProps from the RELEASE file
@@ -205,7 +221,7 @@ ext {
   testClassesDir = useClover ? cloverTestClassesDir : "${jalviewDir}/${test_output_dir}"
 
   channelSuffix = ""
-  backgroundImageText = false
+  backgroundImageText = BACKGROUNDIMAGETEXT
   getdownChannelDir = string("${getdown_website_dir}/${propertiesChannelName}")
   getdownAppBaseDir = string("${jalviewDir}/${getdownChannelDir}/${JAVA_VERSION}")
   getdownArchiveDir = string("${jalviewDir}/${getdown_archive_dir}")
@@ -256,6 +272,7 @@ ext {
       testng_excluded_groups = "Not-bamboo"
     }
     install4jExtraScheme = "jalviewb"
+    backgroundImageText = true
     break
 
     case [ "RELEASE", "JALVIEWJS-RELEASE" ]:
@@ -286,7 +303,7 @@ ext {
     getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
     getdownAppBase = file(getdownAppBaseDir).toURI().toString()
     if (!file("${ARCHIVEDIR}/${package_dir}").exists()) {
-      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution")
+      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution [did not find '${ARCHIVEDIR}/${package_dir}']")
     } else {
       package_dir = string("${ARCHIVEDIR}/${package_dir}")
       buildProperties = string("${ARCHIVEDIR}/${classes_dir}/${build_properties_file}")
@@ -324,6 +341,7 @@ ext {
     install4jSuffix = "Develop"
     install4jExtraScheme = "jalviewd"
     install4jInstallerName = "${jalview_name} Develop Installer"
+    backgroundImageText = true
     break
 
     case "TEST-RELEASE":
@@ -338,6 +356,7 @@ ext {
     install4jSuffix = "Test"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case ~/^SCRATCH(|-[-\w]*)$/:
@@ -361,6 +380,7 @@ ext {
     install4jSuffix = "Test-Local"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case [ "LOCAL", "JALVIEWJS" ]:
@@ -511,16 +531,10 @@ ext {
   // for install4j
   JAVA_MIN_VERSION = JAVA_VERSION
   JAVA_MAX_VERSION = JAVA_VERSION
-  def jreInstallsDir = string(jre_installs_dir)
+  jreInstallsDir = string(jre_installs_dir)
   if (jreInstallsDir.startsWith("~/")) {
     jreInstallsDir = System.getProperty("user.home") + jreInstallsDir.substring(1)
   }
-  macosJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-mac-x64/jre")
-  windowsJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-windows-x64/jre")
-  linuxJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-linux-x64/jre")
-  macosJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_mac_x64.tar.gz")
-  windowsJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_windows_x64.tar.gz")
-  linuxJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_linux_x64.tar.gz")
   install4jDir = string("${jalviewDir}/${install4j_utils_dir}")
   install4jConfFileName = string("jalview-install4j-conf.install4j")
   install4jConfFile = file("${install4jDir}/${install4jConfFileName}")
@@ -557,11 +571,12 @@ ext {
   if (IN_ECLIPSE) {
     jalviewjsTransferSiteJsDir = string(jalviewjsSiteDir)
   } else {
-    jalviewjsTransferSiteJsDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}_js")
+    jalviewjsTransferSiteJsDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}/sitejs")
   }
-  jalviewjsTransferSiteLibDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}_lib")
-  jalviewjsTransferSiteSwingJsDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}_swingjs")
-  jalviewjsTransferSiteCoreDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}_core")
+  jalviewjsTransferSiteLibDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}/lib")
+  jalviewjsTransferSiteSwingJsDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}/swingjs")
+  jalviewjsTransferSiteMergeDir = string("${jalviewjsBuildDir}/merge/${jalviewjs_site_dir}")
+  jalviewjsTransferSiteCoreDir = string("${jalviewjsBuildDir}/tmp/${jalviewjs_site_dir}/core")
   jalviewjsJalviewCoreHtmlFile = string("")
   jalviewjsJalviewCoreName = string(jalviewjs_core_name)
   jalviewjsCoreClasslists = []
@@ -570,11 +585,19 @@ ext {
   jalviewjsJ2sAltSettingsFileName = string("${jalviewDir}/${jalviewjs_j2s_alt_settings}")
   jalviewjsJ2sProps = null
   jalviewjsJ2sPlugin = jalviewjs_j2s_plugin
+  jalviewjsStderrLaunchFilename = "${jalviewjsSiteDir}/"+(file(jalviewjs_stderr_launch).getName())
 
+  closureCompilerJar = "${jalviewDir}/${jalviewjs_closure_compiler}"
+    
   eclipseWorkspace = null
   eclipseBinary = string("")
   eclipseVersion = string("")
+  eclipseProductVersion = string("")
   eclipseDebug = false
+
+  jalviewjsChromiumUserDir = "${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}"
+  jalviewjsChromiumProfileDir = "${ext.jalviewjsChromiumUserDir}/${jalviewjs_chromium_profile_name}"
+
   // ENDEXT
 }
 
@@ -772,6 +795,20 @@ eclipse {
             }
           }
         }
+        // local eclipse settings
+        def localEclipsePropertiesFile = file("${jalviewDirAbsolutePath}/${localEclipseProperties}")
+        if (localEclipsePropertiesFile.exists()) {
+          def eclipse_prefs = new Properties()
+          def ins2 = new FileInputStream(localEclipsePropertiesFile)
+          println("Loading Eclipse Preferences from '${localEclipsePropertiesFile}'")
+          eclipse_prefs.load(ins2)
+          ins2.close()
+          eclipse_prefs.forEach { t, v ->
+            props.putAt(t, v)
+          }
+        } else {
+          println("No local Eclipse Preferences file '${localEclipsePropertiesFile}'")
+        }
       }
     }
 
@@ -1066,7 +1103,7 @@ compileJava {
   // JBP->BS should the print statement in doFirst refer to compile_target_compatibility ?
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   options.encoding = "UTF-8"
   doFirst {
     print ("Setting target compatibility to "+compile_target_compatibility+"\n")
@@ -1078,7 +1115,7 @@ compileJava {
 compileTestJava {
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   doFirst {
     print ("Setting target compatibility to "+targetCompatibility+"\n")
   }
@@ -1734,26 +1771,140 @@ task prepare {
 
 compileJava.dependsOn prepare
 run.dependsOn compileJava
-//run.dependsOn prepare
+compileTestJava.dependsOn compileJava
+
 
 
-//testReportDirName = "test-reports" // note that test workingDir will be $jalviewDir
 test {
-  dependsOn prepare
+  group = "Verification"
+  description = "Runs all testTaskN tasks)"
 
   if (useClover) {
     dependsOn cloverClasses
-   } else { //?
-    dependsOn compileJava //?
+  } else { //?
+    dependsOn testClasses
+  }
+
+  // not running tests in this task
+  exclude "**/*"
+}
+/* testTask0 is the main test task */
+task testTask0(type: Test) {
+  group = "Verification"
+  description = "The main test task. Runs all non-testTaskN-labelled tests (unless excluded)"
+  useTestNG() {
+    includeGroups testng_groups.split(",")
+    excludeGroups testng_excluded_groups.split(",")
+    tasks.withType(Test).matching {it.name.startsWith("testTask") && it.name != name}.all {t -> excludeGroups t.name}
+    preserveOrder true
+    useDefaultListeners=true
   }
+}
 
+/* separated tests */
+task testTask1(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
   useTestNG() {
-    includeGroups testng_groups
-    excludeGroups testng_excluded_groups
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
     preserveOrder true
     useDefaultListeners=true
   }
+}
+
+task testTask2(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+task testTask3(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+
+/* insert more testTaskNs here -- change N to next digit or other string */
+/*
+task testTaskN(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+*/
+
+/*
+ * adapted from https://medium.com/@wasyl/pretty-tests-summary-in-gradle-744804dd676c
+ * to summarise test results from all Test tasks
+ */
+/* START of test tasks results summary */
+import groovy.time.TimeCategory
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+rootProject.ext.testsResults = [] // Container for tests summaries
+
+tasks.withType(Test).matching {t -> t.getName().startsWith("testTask")}.all { testTask ->
+
+  // from original test task
+  if (useClover) {
+    dependsOn cloverClasses
+  } else { //?
+    dependsOn testClasses //?
+  }
+
+  // run main tests first
+  if (!testTask.name.equals("testTask0"))
+    testTask.mustRunAfter "testTask0"
+
+  testTask.testLogging { logging ->
+    events TestLogEvent.FAILED
+//      TestLogEvent.SKIPPED,
+//      TestLogEvent.STANDARD_OUT,
+//      TestLogEvent.STANDARD_ERROR
+
+    exceptionFormat TestExceptionFormat.FULL
+    showExceptions true
+    showCauses true
+    showStackTraces true
+    if (test_output) {
+      showStandardStreams true
+    }
+    info.events = [ TestLogEvent.FAILED ]
+  }
+
+  if (OperatingSystem.current().isMacOsX()) {
+    testTask.systemProperty "apple.awt.UIElement", "true"
+    testTask.environment "JAVA_TOOL_OPTIONS", "-Dapple.awt.UIElement=true"
+  }
+
+
+  ignoreFailures = true // Always try to run all tests for all modules
+
+  afterSuite { desc, result ->
+    if (desc.parent)
+      return // Only summarize results for whole modules
+
+    def resultsInfo = [testTask.project.name, testTask.name, result, TimeCategory.minus(new Date(result.endTime), new Date(result.startTime)), testTask.reports.html.entryPoint]
+
+    rootProject.ext.testsResults.add(resultsInfo)
+  }
 
+  // from original test task
   maxHeapSize = "1024m"
 
   workingDir = jalviewDir
@@ -1772,12 +1923,144 @@ test {
   jvmArgs += additional_compiler_args
 
   doFirst {
+    // this is not perfect yet -- we should only add the commandLineIncludePatterns to the
+    // testTasks that include the tests, and exclude all from the others.
+    // get --test argument
+    filter.commandLineIncludePatterns = test.filter.commandLineIncludePatterns
+    // do something with testTask.getCandidateClassFiles() to see if the test should silently finish because of the
+    // commandLineIncludePatterns not matching anything.  Instead we are doing setFailOnNoMatchingTests(false) below
+
+
     if (useClover) {
       println("Running tests " + (useClover?"WITH":"WITHOUT") + " clover")
     }
   }
+
+
+  /* don't fail on no matching tests (so --tests will run across all testTasks) */
+  testTask.filter.setFailOnNoMatchingTests(false)
+
+  /* ensure the "test" task dependsOn all the testTasks */
+  test.dependsOn testTask
 }
 
+gradle.buildFinished {
+    def allResults = rootProject.ext.testsResults
+
+    if (!allResults.isEmpty()) {
+        printResults allResults
+        allResults.each {r ->
+          if (r[2].resultType == TestResult.ResultType.FAILURE)
+            throw new GradleException("Failed tests!")
+        }
+    }
+}
+
+private static String colString(styler, col, colour, text) {
+  return col?"${styler[colour](text)}":text
+}
+
+private static String getSummaryLine(s, pn, tn, rt, rc, rs, rf, rsk, t, col) {
+  def colour = 'black'
+  def text = rt
+  def nocol = false
+  if (rc == 0) {
+    text = "-----"
+    nocol = true
+  } else {
+    switch(rt) {
+      case TestResult.ResultType.SUCCESS:
+        colour = 'green'
+        break;
+      case TestResult.ResultType.FAILURE:
+        colour = 'red'
+        break;
+      default:
+        nocol = true
+        break;
+    }
+  }
+  StringBuilder sb = new StringBuilder()
+  sb.append("${pn}")
+  if (tn != null)
+    sb.append(":${tn}")
+  sb.append(" results: ")
+  sb.append(colString(s, col && !nocol, colour, text))
+  sb.append(" (")
+  sb.append("${rc} tests, ")
+  sb.append(colString(s, col && rs > 0, 'green', rs))
+  sb.append(" successes, ")
+  sb.append(colString(s, col && rf > 0, 'red', rf))
+  sb.append(" failures, ")
+  sb.append("${rsk} skipped) in ${t}")
+  return sb.toString()
+}
+
+private static void printResults(allResults) {
+
+    // styler from https://stackoverflow.com/a/56139852
+    def styler = 'black red green yellow blue magenta cyan white'.split().toList().withIndex(30).collectEntries { key, val -> [(key) : { "\033[${val}m${it}\033[0m" }] }
+
+    def maxLength = 0
+    def failedTests = false
+    def summaryLines = []
+    def totalcount = 0
+    def totalsuccess = 0
+    def totalfail = 0
+    def totalskip = 0
+    def totaltime = TimeCategory.getSeconds(0)
+    // sort on project name then task name
+    allResults.sort {a, b -> a[0] == b[0]? a[1]<=>b[1]:a[0] <=> b[0]}.each {
+      def projectName = it[0]
+      def taskName = it[1]
+      def result = it[2]
+      def time = it[3]
+      def report = it[4]
+      def summaryCol = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, true)
+      def summaryPlain = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, false)
+      def reportLine = "Report file: ${report}"
+      def ls = summaryPlain.length()
+      def lr = reportLine.length()
+      def m = [ls, lr].max()
+      if (m > maxLength)
+        maxLength = m
+      def info = [ls, summaryCol, reportLine]
+      summaryLines.add(info)
+      failedTests |= result.resultType == TestResult.ResultType.FAILURE
+      totalcount += result.testCount
+      totalsuccess += result.successfulTestCount
+      totalfail += result.failedTestCount
+      totalskip += result.skippedTestCount
+      totaltime += time
+    }
+    def totalSummaryCol = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, true)
+    def totalSummaryPlain = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, false)
+    def tls = totalSummaryPlain.length()
+    if (tls > maxLength)
+      maxLength = tls
+    def info = [tls, totalSummaryCol, null]
+    summaryLines.add(info)
+
+    def allSummaries = []
+    for(sInfo : summaryLines) {
+      def ls = sInfo[0]
+      def summary = sInfo[1]
+      def report = sInfo[2]
+
+      StringBuilder sb = new StringBuilder()
+      sb.append("│" + summary + " " * (maxLength - ls) + "│")
+      if (report != null) {
+        sb.append("\n│" + report + " " * (maxLength - report.length()) + "│")
+      }
+      allSummaries += sb.toString()
+    }
+
+    println "┌${"${"─" * maxLength}"}┐"
+    println allSummaries.join("\n├${"${"─" * maxLength}"}┤\n")
+    println "└${"${"─" * maxLength}"}┘"
+}
+/* END of test tasks results summary */
+
 
 task compileLinkCheck(type: JavaCompile) {
   options.fork = true
@@ -1897,22 +2180,56 @@ task cleanDist {
 }
 
 
+task launcherJar(type: Jar) {
+  manifest {
+      attributes (
+        "Main-Class": shadow_jar_main_class,
+        "Implementation-Version": JALVIEW_VERSION,
+        "Application-Name": applicationName
+      )
+  }
+}
+
 shadowJar {
   group = "distribution"
   description = "Create a single jar file with all dependency libraries merged. Can be run with java -jar"
   if (buildDist) {
     dependsOn makeDist
   }
-  from ("${jalviewDir}/${libDistDir}") {
-    include("*.jar")
-  }
+
+  def jarFiles = fileTree(dir: "${jalviewDir}/${libDistDir}", include: "*.jar", exclude: "regex.jar").getFiles()
+  def groovyJars = jarFiles.findAll {it1 -> file(it1).getName().startsWith("groovy-swing")}
+  def otherJars = jarFiles.findAll {it2 -> !file(it2).getName().startsWith("groovy-swing")}
+  from groovyJars
+  from otherJars
+
   manifest {
-    attributes "Implementation-Version": JALVIEW_VERSION,
-    "Application-Name": applicationName
+    // shadowJar manifest must inheritFrom another Jar task.  Can't set attributes here.
+    inheritFrom(project.tasks.launcherJar.manifest)
+  }
+  // we need to include the groovy-swing Include-Package for it to run in the shadowJar
+  doFirst {
+    def jarFileManifests = []
+    groovyJars.each { jarFile ->
+      def mf = zipTree(jarFile).getFiles().find { it.getName().equals("MANIFEST.MF") }
+      if (mf != null) {
+        jarFileManifests += mf
+      }
+    }
+    manifest {
+      from (jarFileManifests) {
+        eachEntry { details ->
+          if (!details.key.equals("Import-Package")) {
+            details.exclude()
+          }
+        }
+      }
+    }
   }
 
   duplicatesStrategy "INCLUDE"
 
+  // this mainClassName is mandatory but gets ignored due to manifest created in doFirst{}. Set the Main-Class as an attribute in launcherJar instead
   mainClassName = shadow_jar_main_class
   mergeServiceFiles()
   classifier = "all-"+JALVIEW_VERSION+"-j"+JAVA_VERSION
@@ -1966,9 +2283,9 @@ task getdownImages() {
   dependsOn getdownImagesProcess
 }
 
-task getdownWebsite() {
+task getdownWebsiteBuild() {
   group = "distribution"
-  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer"
+  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer. No digest is created."
 
   dependsOn getdownImages
   if (buildDist) {
@@ -2020,8 +2337,8 @@ task getdownWebsite() {
       props.put("getdown_txt_ui.instant_background_image", "${getdownImagesBuildDir}/${getdown_instant_background_image}")
       props.put("getdown_txt_ui.error_background", "${getdownImagesBuildDir}/${getdown_error_background}")
       props.put("getdown_txt_ui.progress_image", "${getdownImagesBuildDir}/${getdown_progress_image}")
-      props.put("getdown_txt_ui.icon", "${getdownImagesBuildDir}/${getdown_icon}")
-      props.put("getdown_txt_ui.mac_dock_icon", "${getdownImagesBuildDir}/${getdown_mac_dock_icon}")
+      props.put("getdown_txt_ui.icon", "${getdownImagesDir}/${getdown_icon}")
+      props.put("getdown_txt_ui.mac_dock_icon", "${getdownImagesDir}/${getdown_mac_dock_icon}")
     }
 
     props.put("getdown_txt_title", jalview_name)
@@ -2080,7 +2397,7 @@ task getdownWebsite() {
           from s
           into "${getdownAppBaseDir}/${getdown_wrapper_script_dir}"
         }
-        getdownTextLines += "resource = ${getdown_wrapper_script_dir}/${script}"
+        getdownTextLines += "xresource = ${getdown_wrapper_script_dir}/${script}"
       }
     }
 
@@ -2222,7 +2539,9 @@ task getdownDigestDir(type: JavaExec) {
 task getdownDigest(type: JavaExec) {
   group = "distribution"
   description = "Digest the getdown website folder"
-  dependsOn getdownWebsite
+
+  dependsOn getdownWebsiteBuild
+
   doFirst {
     classpath = files(getdownLauncher)
   }
@@ -2255,12 +2574,19 @@ task getdown() {
   }
 }
 
+task getdownWebsite {
+  group = "distribution"
+  description = "A task to create the whole getdown channel website dir including digest file"
+
+  dependsOn getdownWebsiteBuild
+  dependsOn getdownDigest
+}
 
 task getdownArchiveBuild() {
   group = "distribution"
   description = "Put files in the archive dir to go on the website"
 
-  dependsOn getdownWebsite
+  dependsOn getdownWebsiteBuild
 
   def v = "v${JALVIEW_VERSION_UNDERSCORES}"
   def vDir = "${getdownArchiveDir}/${v}"
@@ -2561,12 +2887,6 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
     'JAVA_VERSION': JAVA_VERSION,
     'JAVA_INTEGER_VERSION': JAVA_INTEGER_VERSION,
     'VERSION': JALVIEW_VERSION,
-    'MACOS_JAVA_VM_DIR': macosJavaVMDir,
-    'WINDOWS_JAVA_VM_DIR': windowsJavaVMDir,
-    'LINUX_JAVA_VM_DIR': linuxJavaVMDir,
-    'MACOS_JAVA_VM_TGZ': macosJavaVMTgz,
-    'WINDOWS_JAVA_VM_TGZ': windowsJavaVMTgz,
-    'LINUX_JAVA_VM_TGZ': linuxJavaVMTgz,
     'COPYRIGHT_MESSAGE': install4j_copyright_message,
     'BUNDLE_ID': install4jBundleId,
     'INTERNAL_ID': install4jInternalId,
@@ -2576,6 +2896,7 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
     'WRAPPER_LINK': getdownWrapperLink,
     'BASH_WRAPPER_SCRIPT': getdown_bash_wrapper_script,
     'POWERSHELL_WRAPPER_SCRIPT': getdown_powershell_wrapper_script,
+    'BATCH_WRAPPER_SCRIPT': getdown_batch_wrapper_script,
     'WRAPPER_SCRIPT_BIN_DIR': getdown_wrapper_script_dir,
     'INSTALLER_NAME': install4jInstallerName,
     'INSTALL4J_UTILS_DIR': install4j_utils_dir,
@@ -2596,8 +2917,29 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
     'WINDOWS_ICONS_FILE': install4jWindowsIconsFile,
     'PNG_ICON_FILE': install4jPngIconFile,
     'BACKGROUND': install4jBackground,
+  ]
 
+  def varNameMap = [
+    'mac': 'MACOS',
+    'windows': 'WINDOWS',
+    'linux': 'LINUX'
   ]
+  
+  // these are the bundled OS/architecture VMs needed by install4j
+  def osArch = [
+    [ "mac", "x64" ],
+    [ "mac", "aarch64" ],
+    [ "windows", "x64" ],
+    [ "linux", "x64" ],
+    [ "linux", "aarch64" ]
+  ]
+  osArch.forEach { os, arch ->
+    variables[ sprintf("%s_%s_JAVA_VM_DIR", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/jre-%s-%s-%s/jre", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+    // N.B. For some reason install4j requires the below filename to have underscores and not hyphens
+    // otherwise running `gradle installers` generates a non-useful error:
+    // `install4j: compilation failed. Reason: java.lang.NumberFormatException: For input string: "windows"`
+    variables[ sprintf("%s_%s_JAVA_VM_TGZ", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/tgz/jre_%s_%s_%s.tar.gz", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+  }
 
   //println("INSTALL4J VARIABLES:")
   //variables.each{k,v->println("${k}=${v}")}
@@ -2631,8 +2973,6 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
   inputs.dir(getdownAppBaseDir)
   inputs.file(install4jConfFile)
   inputs.file("${install4jDir}/${install4j_info_plist_file_associations}")
-  inputs.dir(macosJavaVMDir)
-  inputs.dir(windowsJavaVMDir)
   outputs.dir("${jalviewDir}/${install4j_build_dir}/${JAVA_VERSION}")
 }
 
@@ -2756,6 +3096,7 @@ task sourceDist(type: Tar) {
   into project.name
 
   def EXCLUDE_FILES=[
+    "dist/*",
     "build/*",
     "bin/*",
     "test-output/",
@@ -2935,12 +3276,14 @@ task jalviewjsEnableAltFileProperty(type: WriteProperties) {
 task jalviewjsSetEclipseWorkspace {
   def propKey = "jalviewjs_eclipse_workspace"
   def propVal = null
+  // see if jalviewjs_eclipse_workspace is set by a property
   if (project.hasProperty(propKey)) {
     propVal = project.getProperty(propKey)
     if (propVal.startsWith("~/")) {
       propVal = System.getProperty("user.home") + propVal.substring(1)
     }
   }
+  // else look for an existing build/jalviewjs/eclipse_workspace_location file
   def propsFileName = "${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_eclipse_workspace_location_file}"
   def propsFile = file(propsFileName)
   def eclipseWsDir = propVal
@@ -2989,13 +3332,14 @@ task jalviewjsSetEclipseWorkspace {
   }
 
   //inputs.property(propKey, eclipseWsDir) // eclipseWsDir only gets set once this task runs, so will be out-of-date
-  outputs.file(propsFileName)
-  outputs.upToDateWhen { eclipseWorkspace.exists() && propsFile.exists() }
+  //outputs.file(propsFileName) // don't want this to be deleted because of falsely "stale" task
+  outputs.upToDateWhen { eclipseWorkspace.exists() && (propsFile.exists() || !writeProps) }
 }
 
 
 task jalviewjsEclipsePaths {
-  def eclipseProduct
+  def eclipseProductFile
+  def eclipseSetupLog
 
   def eclipseRoot = jalviewjs_eclipse_root
   if (eclipseRoot.startsWith("~/")) {
@@ -3004,32 +3348,49 @@ task jalviewjsEclipsePaths {
   if (OperatingSystem.current().isMacOsX()) {
     eclipseRoot += "/Eclipse.app"
     eclipseBinary = "${eclipseRoot}/Contents/MacOS/eclipse"
-    eclipseProduct = "${eclipseRoot}/Contents/Eclipse/.eclipseproduct"
+    eclipseProductFile = "${eclipseRoot}/Contents/Eclipse/.eclipseproduct"
+    eclipseSetupLog = "${eclipseRoot}/Contents/Eclipse/configuration/org.eclipse.oomph.setup/setup.log"
   } else if (OperatingSystem.current().isWindows()) { // check these paths!!
     if (file("${eclipseRoot}/eclipse").isDirectory() && file("${eclipseRoot}/eclipse/.eclipseproduct").exists()) {
       eclipseRoot += "/eclipse"
     }
     eclipseBinary = "${eclipseRoot}/eclipse.exe"
-    eclipseProduct = "${eclipseRoot}/.eclipseproduct"
+    eclipseProductFile = "${eclipseRoot}/.eclipseproduct"
+    eclipseSetupLog = "${eclipseRoot}/configuration/org.eclipse.oomph.setup/setup.log"
   } else { // linux or unix
     if (file("${eclipseRoot}/eclipse").isDirectory() && file("${eclipseRoot}/eclipse/.eclipseproduct").exists()) {
       eclipseRoot += "/eclipse"
-println("eclipseDir exists")
     }
     eclipseBinary = "${eclipseRoot}/eclipse"
-    eclipseProduct = "${eclipseRoot}/.eclipseproduct"
+    eclipseProductFile = "${eclipseRoot}/.eclipseproduct"
+    eclipseSetupLog = "${eclipseRoot}/configuration/org.eclipse.oomph.setup/setup.log"
   }
 
-  eclipseVersion = "4.13" // default
+  eclipseVersion = "unknown" // default
   def assumedVersion = true
-  if (file(eclipseProduct).exists()) {
-    def fis = new FileInputStream(eclipseProduct)
+  if (file(eclipseProductFile).exists()) {
+    def fis = new FileInputStream(eclipseProductFile)
     def props = new Properties()
     props.load(fis)
     eclipseVersion = props.getProperty("version")
     fis.close()
     assumedVersion = false
   }
+  if (file(eclipseSetupLog).exists()) {
+    def productRegex = /(?m)^\[[^\]]+\]\s+Product\s+(org\.eclipse.\S*)/
+    int lineCount = 0
+    file(eclipseSetupLog).eachLine { String line ->
+      def matcher = line =~ productRegex
+      if (matcher.size() > 0) {
+        eclipseProductVersion = matcher[0][1]
+        return true
+      }
+      if (lineCount >= 100) {
+        return true
+      }
+      lineCount++
+    }
+  }
   
   def propKey = "eclipse_debug"
   eclipseDebug = (project.hasProperty(propKey) && project.getProperty(propKey).equals("true"))
@@ -3045,6 +3406,9 @@ println("eclipseDir exists")
 
     if (!assumedVersion) {
       println("ECLIPSE VERSION=${eclipseVersion}")
+      if (eclipseProductVersion.length() != 0) {
+        println("ECLIPSE PRODUCT=${eclipseProductVersion}")
+      }
     }
   }
 }
@@ -3104,41 +3468,37 @@ jalviewjsEclipseCopyDropins.finalizedBy jalviewjsCleanEclipse
 */
 
 
-task jalviewjsTransferUnzipSwingJs {
-  def file_zip = "${jalviewDir}/${jalviewjs_swingjs_zip}"
+task jalviewjsTransferUnzipSwingJs(type: Copy) {
+  def swingJsZipFile = "${jalviewDir}/${jalviewjs_swingjs_zip}"
+  from zipTree( "${jalviewDir}/${jalviewjs_swingjs_zip}" )
+  into "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}"
 
-  doLast {
-    copy {
-      from zipTree(file_zip)
-      into "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}"
-    }
-  }
-
-  inputs.file file_zip
-  outputs.dir "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}"
+  inputs.file swingJsZipFile
 }
 
 
-task jalviewjsTransferUnzipLib {
-  def zipFiles = fileTree(dir: "${jalviewDir}/${jalviewjs_libjs_dir}", include: "*.zip")
+task jalviewjsTransferUnzipLib(type: Copy) {
+  def zipFiles = fileTree(dir: "${jalviewDir}/${jalviewjs_libjs_dir}", include: "*.zip").sort()
 
-  doLast {
-    zipFiles.each { file_zip -> 
-      copy {
-        from zipTree(file_zip)
-        into "${jalviewDir}/${jalviewjsTransferSiteLibDir}"
+  zipFiles.each { file_zip ->
+    from zipTree(file_zip)
+
+    // The following replace() is needed due to a mismatch in Jmol calls to
+    // colorPtToFFRGB$javajs_util_T3d when only colorPtToFFRGB$javajs_util_T3 is defined
+    // in the SwingJS.zip (github or the one distributed with JSmol)
+    if (file_zip.getName().startsWith("Jmol-SwingJS")) {
+      filter { line ->
+        def l = ""
+        while(!line.equals(l)) {
+          line = line.replace('colorPtToFFRGB$javajs_util_T3d', 'colorPtToFFRGB$javajs_util_T3')
+          l = line
+        }
+        return line
       }
     }
-  }
-
-  inputs.files zipFiles
-  outputs.dir "${jalviewDir}/${jalviewjsTransferSiteLibDir}"
-}
-
 
-task jalviewjsTransferUnzipAllLibs {
-  dependsOn jalviewjsTransferUnzipSwingJs
-  dependsOn jalviewjsTransferUnzipLib
+  }
+  into "${jalviewDir}/${jalviewjsTransferSiteLibDir}"
 }
 
 
@@ -3167,7 +3527,6 @@ task jalviewjsCreateJ2sSettings(type: WriteProperties) {
 
   if (! IN_ECLIPSE) {
     inputs.properties(jalviewjsJ2sProps)
-    outputs.file(jalviewjsJ2sAltSettingsFileName)
   }
 }
 
@@ -3179,46 +3538,81 @@ task jalviewjsEclipseSetup {
 }
 
 
-task jalviewjsSyncAllLibs (type: Sync) {
-  dependsOn jalviewjsTransferUnzipAllLibs
-  def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteLibDir}")
-  inputFiles += fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}")
+task jalviewjsSyncLibs (type: Sync) {
+  dependsOn jalviewjsTransferUnzipLib
+  dependsOn jalviewjsTransferUnzipSwingJs
+
+  def inputDir = file("${jalviewDir}/${jalviewjsTransferSiteLibDir}")
+  def inputFiles = fileTree(dir: inputDir)
   def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
   into outputDir
   def outputFiles = []
-  rename { filename ->
-    outputFiles += "${outputDir}/${filename}"
-    null
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
   }
   preserve {
-    include "**"
+    include "**/*"
+  }
+
+  duplicatesStrategy "EXCLUDE"
+
+  outputs.files outputFiles
+  inputs.files inputFiles
+}
+
+task jalviewjsSyncSwingJS (type: Sync) {
+  dependsOn jalviewjsTransferUnzipSwingJs
+  mustRunAfter jalviewjsSyncLibs
+
+  def inputDir = file("${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}")
+  def inputFiles = fileTree(dir: inputDir)
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
+
+  from inputFiles
+  into outputDir
+  def outputFiles = []
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
+  }
+  preserve {
+    include "**/*"
   }
 
-  // should this be exclude really ?
   duplicatesStrategy "INCLUDE"
 
   outputs.files outputFiles
   inputs.files inputFiles
 }
 
+task jalviewjsSyncAllLibs {
+  dependsOn jalviewjsSyncLibs
+  dependsOn jalviewjsSyncSwingJS
+}
+
 
 task jalviewjsSyncResources (type: Sync) {
   dependsOn buildResources
 
-  def inputFiles = fileTree(dir: resourcesBuildDir)
+  def inputDir = file(resourcesBuildDir)
+  def inputFiles = fileTree(dir: inputDir)
   def outputDir = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
 
   from inputFiles
   into outputDir
   def outputFiles = []
-  rename { filename ->
-    outputFiles += "${outputDir}/${filename}"
-    null
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
   }
   preserve {
-    include "**"
+    include "**/*"
   }
   outputs.files outputFiles
   inputs.files inputFiles
@@ -3226,19 +3620,22 @@ task jalviewjsSyncResources (type: Sync) {
 
 
 task jalviewjsSyncSiteResources (type: Sync) {
-  def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjs_site_resource_dir}")
+  def inputDir = file("${jalviewDir}/${jalviewjs_site_resource_dir}")
+  def inputFiles = fileTree(dir: inputDir)
   def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
   into outputDir
   def outputFiles = []
-  rename { filename ->
-    outputFiles += "${outputDir}/${filename}"
-    null
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
   }
   preserve {
-    include "**"
+    include "**/*"
   }
+
   outputs.files outputFiles
   inputs.files inputFiles
 }
@@ -3246,19 +3643,24 @@ task jalviewjsSyncSiteResources (type: Sync) {
 
 task jalviewjsSyncBuildProperties (type: Sync) {
   dependsOn createBuildProperties
-  def inputFiles = [file(buildProperties)]
+
+  def f = file(buildProperties)
+  def inputDir = f.getParentFile()
+  def inputFiles = [f]
   def outputDir = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
 
   from inputFiles
   into outputDir
   def outputFiles = []
-  rename { filename ->
-    outputFiles += "${outputDir}/${filename}"
-    null
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
   }
   preserve {
-    include "**"
+    include "**/*"
   }
+
   outputs.files outputFiles
   inputs.files inputFiles
 }
@@ -3280,7 +3682,8 @@ task jalviewjsProjectImport(type: Exec) {
   }
 
   //def projdir = eclipseWorkspace.getPath()+"/.metadata/.plugins/org.eclipse.core.resources/.projects/jalview/org.eclipse.jdt.core"
-  def projdir = eclipseWorkspace.getPath()+"/.metadata/.plugins/org.eclipse.core.resources/.projects/jalview"
+  def projdir = eclipseWorkspace.getPath()+"/.metadata/.plugins/org.eclipse.core.resources/.projects/${eclipse_project_name}"
+
   executable(eclipseBinary)
   args(["-nosplash", "--launcher.suppressErrors", "-application", "com.seeq.eclipse.importprojects.headlessimport", "-data", eclipseWorkspace.getPath(), "-import", jalviewDirAbsolutePath])
   if (eclipseDebug) {
@@ -3290,19 +3693,22 @@ task jalviewjsProjectImport(type: Exec) {
   if (!IN_ECLIPSE) {
     args += [ "-D${j2sHeadlessBuildProperty}=true" ]
     args += [ "-D${jalviewjs_j2s_alt_file_property}=${jalviewjsJ2sAltSettingsFileName}" ]
+    inputs.file("${jalviewjsJ2sAltSettingsFileName}")
   }
 
-  inputs.file("${jalviewDir}/.project")
-  outputs.upToDateWhen { 
-    file(projdir).exists()
-  }
+  outputs.upToDateWhen( {
+    if (IN_ECLIPSE) {
+      return true
+    }
+    def projDirExists = file(projdir).exists()
+    return projDirExists
+  } )
 }
 
-
+// jalviewjs_eclipse_workspace_location_file
 task jalviewjsTranspile(type: Exec) {
-  dependsOn jalviewjsEclipseSetup 
   dependsOn jalviewjsProjectImport
-  dependsOn jalviewjsEclipsePaths
+
   if (!IN_ECLIPSE) {
     dependsOn jalviewjsEnableAltFileProperty
   }
@@ -3337,11 +3743,12 @@ task jalviewjsTranspile(type: Exec) {
     def logOutFileName = "${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_j2s_transpile_stdout}"
     def logOutFile = file(logOutFileName)
     logOutFile.createNewFile()
-    logOutFile.text = """ROOT: ${jalviewjs_eclipse_root}
-BINARY: ${eclipseBinary}
-VERSION: ${eclipseVersion}
-WORKSPACE: ${eclipseWorkspace}
-DEBUG: ${eclipseDebug}
+    def info = """ROOT: ${jalviewjs_eclipse_root}
+ECLIPSE BINARY: ${eclipseBinary}
+ECLIPSE VERSION: ${eclipseVersion}
+ECLIPSE PRODUCT: ${eclipseProductVersion}
+ECLIPSE WORKSPACE: ${eclipseWorkspace}
+ECLIPSE DEBUG: ${eclipseDebug}
 ----
 """
     def logOutFOS = new FileOutputStream(logOutFile, true) // true == append
@@ -3367,12 +3774,58 @@ DEBUG: ${eclipseDebug}
         logErrFOS,
         stderr)
     }
+    standardOutput.write(string(info).getBytes("UTF-8"))
   }
 
   doLast {
-    if (stdout.toString().contains("Error processing ")) {
+    def transpileError = false
+    def j2sIsActive = false
+    def j2sBuildStarting = false
+    def compilingLines = 0
+    def j2sBuildingJavascript = false
+    def j2sBuildingJavascriptRegex = /(?m)^J2S building JavaScript for (\d+) files/
+    def numFiles = 0
+    def transpilingLines = 0
+    stdout.toString().eachLine { String line ->
+      if (line.startsWith("J2S isActive true")) {
+        j2sIsActive = true
+      }
+      if (line.startsWith("J2S buildStarting")) {
+        j2sBuildStarting = true
+      }
+      if (line =~ / Compiling /) {
+        compilingLines++
+      }
+      if (!j2sBuildingJavascript) {
+        def matcher = line =~ j2sBuildingJavascriptRegex
+        if (matcher.size() > 0) {
+          numFiles = Integer.valueOf(matcher[0][1])
+          j2sBuildingJavascript = true
+        }
+      }
+      if (line.startsWith("J2S transpiling ")) {
+        transpilingLines++
+      }
+      if (line.contains("Error processing ")) {
+        transpileError = true
+      }
+    }
+    
+    println("J2S IS ACTIVE=${j2sIsActive}")
+    println("J2S BUILD STARTING=${j2sBuildStarting}")
+    println("J2S BUILDING JAVASCRIPT=${j2sBuildingJavascript}")
+    println("NUM FILES=${numFiles}")
+    println("COMPILING LINES=${compilingLines}")
+    println("TRANSPILING LINES=${transpilingLines}")
+    println("TRANSPILE ERROR=${transpileError}")
+    
+    if (!j2sIsActive
+        || transpileError
+        || (j2sBuildStarting && transpilingLines == 0)
+        || (transpilingLines < compilingLines)
+        || (transpilingLines != numFiles)
+        ) {
       // j2s did not complete transpile
-      //throw new TaskExecutionException("Error during transpilation:\n${stderr}\nSee eclipse transpile log file '${jalviewDir}/${jalviewjsBuildDir}/${jalviewjs_j2s_transpile_stdout}'")
       if (jalviewjs_ignore_transpile_errors.equals("true")) {
         println("IGNORING TRANSPILE ERRORS")
         println("See eclipse transpile log file '${jalviewDir}/${jalviewjsBuildDir}/${jalviewjs_j2s_transpile_stdout}'")
@@ -3382,9 +3835,127 @@ DEBUG: ${eclipseDebug}
     }
   }
 
+  if (IN_ECLIPSE) {
+    inputs.file(jalviewjsJ2sSettingsFileName)
+  } else {
+    inputs.file(jalviewjsJ2sAltSettingsFileName)
+  }
   inputs.dir("${jalviewDir}/${sourceDir}")
-  outputs.dir("${jalviewDir}/${jalviewjsTransferSiteJsDir}")
-  outputs.upToDateWhen( { file("${jalviewDir}/${jalviewjsTransferSiteJsDir}${jalviewjs_server_resource}").exists() } )
+
+  def inputJavaDir = file("${jalviewDir}/${sourceDir}")
+  def inputJavaFiles = fileTree(dir: inputJavaDir)
+  def outputJsDir = "${jalviewDir}/${jalviewjsTransferSiteJsDir}"
+  def outputJsFiles = []
+  inputJavaFiles.each{ file ->
+    def rfile = inputJavaDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputJsDir}/${rfile}")
+    def ofilenamejs = ofile.getPath()
+    if (ofilenamejs.endsWith(".java")) {
+      ofilenamejs = ofilenamejs.substring(0,ofilenamejs.length()-4)+"js"
+    }
+    outputJsFiles += "${ofilenamejs}"
+  }
+
+  outputs.files outputJsFiles
+  outputs.file("${jalviewDir}/${jalviewjsTransferSiteJsDir}${jalviewjs_server_resource}")
+}
+
+
+task jalviewjsTransferSiteMergeSiteJsDir (type: Copy) {
+  dependsOn jalviewjsTranspile
+
+  def inputDir = file("${jalviewDir}/${jalviewjsTransferSiteJsDir}")
+  def outputDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}"
+  into outputDir
+  from {
+    def inputFiles = fileTree(dir: inputDir)
+    return inputFiles
+  }
+
+  includeEmptyDirs = false
+  exclude "**/*.html"
+  exclude "**/*.htm"
+
+  // should this be exclude really ? No, swingjs dir should be transferred last (and overwrite)
+  duplicatesStrategy "INCLUDE"
+
+  // SiteJs files should take priority and write over existing files if different
+  // so we define the output files
+  outputs.upToDateWhen(
+    {
+      def transpiledFiles = jalviewjsTransferSiteMergeSiteJsDir.getOutputs().getFiles()
+      def inputFiles = fileTree(dir: inputDir)
+      if (inputFiles.size() < transpiledFiles.size()) {
+        return false
+      }
+      def retVal = ! inputFiles.any { file ->
+        def rfile = inputDir.toPath().relativize(file.toPath())
+        def ofile = new File("${outputDir}/${rfile}")
+        if (!ofile.exists() || ofile.lastModified() < file.lastModified()) {
+          return true // this is NOTted to false
+        }
+      }
+
+      return retVal
+    }
+  )
+
+  inputs.files jalviewjsTranspile
+}
+
+task jalviewjsTransferSiteMergeLibDir (type: Copy) {
+  dependsOn jalviewjsTransferUnzipLib
+
+  def outputDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}"
+
+  // This takes the outputs of jalviewjsTransferUnzipLib
+  from jalviewjsTransferUnzipLib
+  into outputDir
+
+  includeEmptyDirs = false
+  exclude "**/*.html"
+  exclude "**/*.htm"
+
+  // don't overwrite files in the destination
+  // Note, this closure gets run at run stage not config stage
+  eachFile {
+    if (it.getRelativePath().getFile(file(outputDir)).exists()) {
+      it.exclude()
+    }
+  }
+
+  duplicatesStrategy "INCLUDE"
+}
+
+task jalviewjsTransferSiteMergeSwingJsDir (type: Copy) {
+  dependsOn jalviewjsTransferUnzipSwingJs
+
+  def outputDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}"
+
+  // This takes the outputs of jalviewjsTransferUnzipSwingJs
+  from jalviewjsTransferUnzipSwingJs
+  into outputDir
+
+  includeEmptyDirs = false
+  exclude "**/*.html"
+  exclude "**/*.htm"
+
+  // DO overwrite files in the destination
+  
+  // should this be exclude really ? No, swingjs dir should be transferred last (and overwrite)
+  duplicatesStrategy "INCLUDE"
+}
+
+// we run after SiteJs and exclude overwriting files
+jalviewjsTransferSiteMergeLibDir.mustRunAfter jalviewjsTransferSiteMergeSiteJsDir
+jalviewjsTransferSiteMergeLibDir.mustRunAfter jalviewjsTransferSiteMergeSwingJsDir
+// we run this last, overwriting files from sitejs and lib, to ensure a consistent SwingJS
+jalviewjsTransferSiteMergeSwingJsDir.mustRunAfter jalviewjsTransferSiteMergeSiteJsDir
+
+task jalviewjsTransferSiteMergeDirs {
+  dependsOn jalviewjsTransferSiteMergeLibDir
+  dependsOn jalviewjsTransferSiteMergeSwingJsDir
+  dependsOn jalviewjsTransferSiteMergeSiteJsDir
 }
 
 
@@ -3402,6 +3973,7 @@ def jalviewjsCallCore(String name, FileCollection list, String prefixFile, Strin
 
   def coreTop = file(prefixFile)
   def coreBottom = file(suffixFile)
+  def missingFiles = []
   coreFile.getParentFile().mkdirs()
   coreFile.createNewFile()
   coreFile.write( coreTop.getText("UTF-8") )
@@ -3415,6 +3987,7 @@ def jalviewjsCallCore(String name, FileCollection list, String prefixFile, Strin
       msg = "...file '"+f.getPath()+"' does not exist, skipping"
       println(msg)
       logOutFile.append(msg+"\n")
+      missingFiles += f
     }
   }
   coreFile.append( coreBottom.getText("UTF-8") )
@@ -3426,11 +3999,11 @@ def jalviewjsCallCore(String name, FileCollection list, String prefixFile, Strin
   def logErrFOS = logOutFOS
 
   javaexec {
-    classpath = files(["${jalviewDir}/${jalviewjs_closure_compiler}"])
+    classpath = files([closureCompilerJar])
     main = "com.google.javascript.jscomp.CommandLineRunner"
     jvmArgs = [ "-Dfile.encoding=UTF-8" ]
-    args = [ "--compilation_level", "SIMPLE_OPTIMIZATIONS", "--warning_level", "QUIET", "--charset", "UTF-8", "--js", jsfile, "--js_output_file", zjsfile ]
-    maxHeapSize = "2g"
+    args = [ "--compilation_level", jalviewjs_closure_compiler_optimization_level, "--warning_level", "QUIET", "--charset", "UTF-8", "--js", jsfile, "--js_output_file", zjsfile ]
+    maxHeapSize = "4g"
 
     msg = "\nRunning '"+commandLine.join(' ')+"'\n"
     println(msg)
@@ -3457,21 +4030,25 @@ def jalviewjsCallCore(String name, FileCollection list, String prefixFile, Strin
     }
   }
   msg = "--"
+  if (missingFiles.size() > 0) {
+    msg += "\n!!! These files were listed but missing:\n"
+    missingFiles.each { file -> msg += "!!!  " + file.getPath() + "\n" }
+    msg = "--"
+  }
   println(msg)
   logOutFile.append(msg+"\n")
 }
 
 
-task jalviewjsBuildAllCores {
+task jalviewjsBuildCore {
   group "JalviewJS"
   description "Build the core js lib closures listed in the classlists dir"
-  dependsOn jalviewjsTranspile
-  dependsOn jalviewjsTransferUnzipSwingJs
+  dependsOn jalviewjsTransferSiteMergeDirs
 
-  def j2sDir = "${jalviewDir}/${jalviewjsTransferSiteJsDir}/${jalviewjs_j2s_subdir}"
-  def swingJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}/${jalviewjs_j2s_subdir}"
-  def libJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteLibDir}/${jalviewjs_j2s_subdir}"
-  def jsDir = "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}/${jalviewjs_js_subdir}"
+  def j2sDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}/${jalviewjs_j2s_subdir}"
+  def swingJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}/${jalviewjs_j2s_subdir}"
+  def libJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}/${jalviewjs_j2s_subdir}"
+  def jsDir = "${jalviewDir}/${jalviewjsTransferSiteMergeDir}/${jalviewjs_js_subdir}"
   def outputDir = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}/${jalviewjs_j2s_subdir}/core"
   def prefixFile = "${jsDir}/core/coretop2.js"
   def suffixFile = "${jsDir}/core/corebottom2.js"
@@ -3490,8 +4067,7 @@ task jalviewjsBuildAllCores {
     ]
   }
 
-  // _jmol and _jalview cores. Add any other peculiar classlist.txt files here
-  //classlistFiles += [ 'file': file("${jalviewDir}/${jalviewjs_classlist_jmol}"), 'name': "_jvjmol" ]
+  // _jalview core
   classlistFiles += [ 'file': file("${jalviewDir}/${jalviewjs_classlist_jalview}"), 'name': jalviewjsJalviewCoreName ]
 
   jalviewjsCoreClasslists = []
@@ -3532,65 +4108,18 @@ task jalviewjsBuildAllCores {
     outputs.file(zjsfile)
   }
   
-  // _stevesoft core. add any cores without a classlist here (and the inputs and outputs)
-  def stevesoftClasslistName = "_stevesoft"
-  def stevesoftClasslist = [
-    'jsfile': "${outputDir}/core${stevesoftClasslistName}.js",
-    'zjsfile': "${outputDir}/core${stevesoftClasslistName}.z.js",
-    'list': fileTree(dir: j2sDir, include: "com/stevesoft/pat/**/*.js"),
-    'name': stevesoftClasslistName
-  ]
-  jalviewjsCoreClasslists += stevesoftClasslist
-  inputs.files(stevesoftClasslist['list'])
-  outputs.file(stevesoftClasslist['jsfile'])
-  outputs.file(stevesoftClasslist['zjsfile'])
-
-  // _all core
-  def allClasslistName = "_all"
-  def allJsFiles = fileTree(dir: j2sDir, include: "**/*.js")
-  allJsFiles += fileTree(
-    dir: libJ2sDir,
-    include: "**/*.js",
-    excludes: [
-      // these exlusions are files that the closure-compiler produces errors for. Should fix them
-      "**/org/jmol/jvxl/readers/IsoIntersectFileReader.js",
-      "**/org/jmol/export/JSExporter.js"
-    ]
-  )
-  allJsFiles += fileTree(
-    dir: swingJ2sDir,
-    include: "**/*.js",
-    excludes: [
-      // these exlusions are files that the closure-compiler produces errors for. Should fix them
-      "**/sun/misc/Unsafe.js",
-      "**/swingjs/jquery/jquery-editable-select.js",
-      "**/swingjs/jquery/j2sComboBox.js",
-      "**/sun/misc/FloatingDecimal.js"
-    ]
-  )
-  def allClasslist = [
-    'jsfile': "${outputDir}/core${allClasslistName}.js",
-    'zjsfile': "${outputDir}/core${allClasslistName}.z.js",
-    'list': allJsFiles,
-    'name': allClasslistName
-  ]
-  // not including this version of "all" core at the moment
-  //jalviewjsCoreClasslists += allClasslist
-  inputs.files(allClasslist['list'])
-  outputs.file(allClasslist['jsfile'])
-  outputs.file(allClasslist['zjsfile'])
-
   doFirst {
     def logOutFile = file("${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_j2s_closure_stdout}")
     logOutFile.getParentFile().mkdirs()
     logOutFile.createNewFile()
-    logOutFile.write(getDate("yyyy-MM-dd HH:mm:ss")+" jalviewjsBuildAllCores\n----\n")
+    logOutFile.write(getDate("yyyy-MM-dd HH:mm:ss")+" jalviewjsBuildCore\n----\n")
 
     jalviewjsCoreClasslists.each {
       jalviewjsCallCore(it.name, it.list, prefixFile, suffixFile, it.jsfile, it.zjsfile, logOutFile, jalviewjs_j2s_to_console.equals("true"))
     }
   }
 
+  inputs.file(closureCompilerJar)
 }
 
 
@@ -3620,7 +4149,8 @@ def jalviewjsPublishCoreTemplate(String coreName, String templateName, File inpu
 
 
 task jalviewjsPublishCoreTemplates {
-  dependsOn jalviewjsBuildAllCores
+  dependsOn jalviewjsBuildCore
+
   def inputFileName = "${jalviewDir}/${j2s_coretemplate_html}"
   def inputFile = file(inputFileName)
   def outputDir = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}"
@@ -3643,29 +4173,43 @@ task jalviewjsPublishCoreTemplates {
 
 
 task jalviewjsSyncCore (type: Sync) {
-  dependsOn jalviewjsBuildAllCores
+  dependsOn jalviewjsBuildCore
   dependsOn jalviewjsPublishCoreTemplates
-  def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteCoreDir}")
+
+  def inputDir = file("${jalviewDir}/${jalviewjsTransferSiteCoreDir}")
+  def inputFiles = fileTree(dir: inputDir)
   def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
   into outputDir
   def outputFiles = []
-  rename { filename ->
-    outputFiles += "${outputDir}/${filename}"
-    null
+  inputFiles.each{ file ->
+    def rfile = inputDir.toPath().relativize(file.toPath())
+    def ofile = new File("${outputDir}/${rfile}")
+    outputFiles += "${ofile}"
   }
   preserve {
-    include "**"
+    include "**/*"
   }
+
   outputs.files outputFiles
   inputs.files inputFiles
 }
 
 
 // this Copy version of TransferSiteJs will delete anything else in the target dir
+task jalviewjsCopyTransferSiteMergeDir(type: Copy) {
+  dependsOn jalviewjsTransferSiteMergeDirs
+
+  from "${jalviewDir}/${jalviewjsTransferSiteMergeDir}"
+  into "${jalviewDir}/${jalviewjsSiteDir}"
+}
+
+
+// this Copy version of TransferSiteJs will delete anything else in the target dir
 task jalviewjsCopyTransferSiteJs(type: Copy) {
   dependsOn jalviewjsTranspile
+
   from "${jalviewDir}/${jalviewjsTransferSiteJsDir}"
   into "${jalviewDir}/${jalviewjsSiteDir}"
 }
@@ -3677,7 +4221,7 @@ task jalviewjsSyncTransferSiteJs(type: Sync) {
   include "**/*.*"
   into "${jalviewDir}/${jalviewjsSiteDir}"
   preserve {
-    include "**"
+    include "**/*"
   }
 }
 
@@ -3696,7 +4240,7 @@ jalviewjsSyncBuildProperties.mustRunAfter jalviewjsSyncTransferSiteJs
 task jalviewjsPrepareSite {
   group "JalviewJS"
   description "Prepares the website folder including unzipping files and copying resources"
-  dependsOn jalviewjsSyncAllLibs
+  //dependsOn jalviewjsSyncAllLibs // now using jalviewjsCopyTransferSiteMergeDir
   dependsOn jalviewjsSyncResources
   dependsOn jalviewjsSyncSiteResources
   dependsOn jalviewjsSyncBuildProperties
@@ -3707,7 +4251,7 @@ task jalviewjsPrepareSite {
 task jalviewjsBuildSite {
   group "JalviewJS"
   description "Builds the whole website including transpiled code"
-  dependsOn jalviewjsCopyTransferSiteJs
+  dependsOn jalviewjsCopyTransferSiteMergeDir
   dependsOn jalviewjsPrepareSite
 }
 
@@ -3970,8 +4514,165 @@ task eclipseAutoBuildTask {
 }
 
 
+task jalviewjsCopyStderrLaunchFile(type: Copy) {
+  from file(jalviewjs_stderr_launch)
+  into jalviewjsSiteDir
+
+  inputs.file jalviewjs_stderr_launch
+  outputs.file jalviewjsStderrLaunchFilename
+}
+
+task cleanJalviewjsChromiumUserDir {
+  doFirst {
+    delete jalviewjsChromiumUserDir
+  }
+  outputs.dir jalviewjsChromiumUserDir
+  // always run when depended on
+  outputs.upToDateWhen { !file(jalviewjsChromiumUserDir).exists() }
+}
+
+task jalviewjsChromiumProfile {
+  dependsOn cleanJalviewjsChromiumUserDir
+  mustRunAfter cleanJalviewjsChromiumUserDir
+
+  def firstRun = file("${jalviewjsChromiumUserDir}/First Run")
+
+  doFirst {
+    mkdir jalviewjsChromiumProfileDir
+    firstRun.text = ""
+  }
+  outputs.file firstRun
+}
+
+task jalviewjsLaunchTest {
+  group "Test"
+  description "Check JalviewJS opens in a browser"
+  dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsCopyStderrLaunchFile
+  dependsOn jalviewjsChromiumProfile
+
+  def macOS = OperatingSystem.current().isMacOsX()
+  def chromiumBinary = macOS ? jalviewjs_macos_chromium_binary : jalviewjs_chromium_binary
+  if (chromiumBinary.startsWith("~/")) {
+    chromiumBinary = System.getProperty("user.home") + chromiumBinary.substring(1)
+  }
+  
+  def stdout
+  def stderr
+  doFirst {
+    def timeoutms = Integer.valueOf(jalviewjs_chromium_overall_timeout) * 1000
+    
+    def binary = file(chromiumBinary)
+    if (!binary.exists()) {
+      throw new StopExecutionException("Could not find chromium binary '${chromiumBinary}'. Cannot run task ${name}.")
+    }
+    stdout = new ByteArrayOutputStream()
+    stderr = new ByteArrayOutputStream()
+    def execStdout
+    def execStderr
+    if (jalviewjs_j2s_to_console.equals("true")) {
+      execStdout = new org.apache.tools.ant.util.TeeOutputStream(
+        stdout,
+        System.out)
+      execStderr = new org.apache.tools.ant.util.TeeOutputStream(
+        stderr,
+        System.err)
+    } else {
+      execStdout = stdout
+      execStderr = stderr
+    }
+    // macOS seems okay now with timeout arguments
+    def execArgs = [
+      "--virtual-time-budget=${timeoutms}",
+      "--timeout=${timeoutms}",
+    ]
+    execArgs += [
+      "--no-sandbox", // --no-sandbox IS USED BY THE THORIUM APPIMAGE ON THE BUILDSERVER
+      "--headless=new",
+      "--disable-gpu",
+      "--user-data-dir=${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}",
+      "--profile-directory=${jalviewjs_chromium_profile_name}",
+      "--allow-file-access-from-files",
+      "--enable-logging=stderr",
+      "file://${jalviewDirAbsolutePath}/${jalviewjsStderrLaunchFilename}"
+    ]
+
+    java.lang.Runnable runChrome = () -> {
+        exec {
+          standardOutput = execStdout
+          errorOutput = execStderr
+          executable(chromiumBinary)
+          args(execArgs)
+          println "COMMAND: '"+commandLine.join(" ")+"'"
+        }
+      }
+
+    if (macOS) {
+    // we create our own timeout executor as --timeout doesn't work on macOS
+      ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
+
+      Future f1 = executor.submit( runChrome )
+
+      def noChangeBytes = 0
+      def noChangeIterations = 0
+      executor.scheduleAtFixedRate(
+        () -> {
+          String stderrString = stderr.toString()
+          // shutdown the task if we have a success string
+          if (stderrString.contains(jalviewjs_desktop_init_string)) {
+            f1.cancel(true)
+            Thread.sleep(100)
+            executor.shutdownNow()
+          } else
+          // if no change in stderr for 10s then also end
+          if (noChangeIterations >= jalviewjs_chromium_idle_timeout) {
+            executor.shutdownNow()
+          }
+          if (stderrString.length() == noChangeBytes) {
+            noChangeIterations++
+          } else {
+            noChangeBytes = stderrString.length()
+            noChangeIterations = 0
+          }
+        },
+        200, 200, TimeUnit.MILLISECONDS)
+
+      executor.schedule(new Runnable(){
+        public void run(){
+          f1.cancel(true)
+          executor.shutdownNow()
+        }
+      }, timeoutms, TimeUnit.MILLISECONDS)
+
+      executor.awaitTermination(timeoutms+200, TimeUnit.MILLISECONDS)
+      f1.cancel(true)
+      executor.shutdownNow()
+    } else {
+      // just run chrome and rely on --virtual-time-budget and --timeout
+      runChrome.run()
+    }
+
+  }
+  
+  doLast {
+    def found = false
+    stderr.toString().eachLine { line ->
+      if (line.contains(jalviewjs_desktop_init_string)) {
+        println("Found line '"+line+"'")
+        found = true
+        return
+      }
+    }
+    if (!found) {
+      throw new GradleException("Could not find evidence of Desktop launch in JalviewJS.")
+    }
+  }
+}
+  
+
 task jalviewjs {
   group "JalviewJS"
-  description "Build the site"
+  description "Build the JalviewJS site and run the launch test"
   dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsLaunchTest
 }