diff --git a/ReadMe.md b/ReadMe.md index ac323cc..334c6c2 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -33,7 +33,10 @@ the logic flow is too complex to correct without a significant rewrite. Features -------- -* Uses similar options to the original ImageJ Launcher, si IJP Launcher can be drop-in replacement +Here are the futures that are already implemented (see release notes for futures ofa specific release): + +* Uses similar options to the original ImageJ Launcher, so IJP Launcher can be used as a drop-in replacement +* Intended to be used with Java 11 or newer (the original launcher can be used for Java 8) * Provides native executable for various OS/Hardware systems - Windows - Mac OS X Arm64 (Apple Silicon) @@ -48,7 +51,7 @@ Features - Search ImageJ directory for available Java executables * Determines the amount of memory used by JVM based on total system memory use 75% of the max * Determines available `imagej-launcher*.jar` -* Performs updates pending after the last time ImageJ was closed +* **Performs updates** pending after the last time ImageJ was closed Full List of Command Line Options --------------------------------- @@ -116,9 +119,22 @@ and "IJP-ImageJ-Launcher-0.1.0-macosx-arm64.command", save them to the `Fiji.app The "*.command" file is a helper that can be used to launch Fiji without using command prompt. Future versions of the IJP Launcher, after v.0.1.0, may eliminate the need for using this file. -**7. Start ImageJ** +**7. Set Executable Permissions** -In the `Fiji.app` folder double-click on `IJP-ImageL-Launcher-0.1.0-macosx-arm64.command` file (note the extension "* +When you download launcher files they may be saved without executable permissions. + +* Open terminal +* Navigate to the Fiji.app folder, for instance, `cd ~/Download/Fiji.app` +* Add executable permission to the launcher and the "*.command" file using + +```shell +chmod +x IJP-ImageJ-Launcher-0.1.0-macosx-arm64 +chmod +x IJP-ImageJ-Launcher-0.1.0-macosx-arm64.command +``` + +**8. Start ImageJ** + +You can start `IJP-ImageL-Launcher-0.1.0-macosx-arm64` from command line or in the `Fiji.app` folder double-click on `IJP-ImageL-Launcher-0.1.0-macosx-arm64.command` file (note the extension "* .command") That should start Fiji. You may need to open Settings and allow the IJP ImageJ Launcher to run. diff --git a/build.sbt b/build.sbt index a81f4c8..e6e6f49 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ -scalaVersion := "3.3.0" +scalaVersion := "3.3.0" //name := "IJP-ImageJ-Launcher" -version := "0.1.0" +version := "0.1.1-SNAPSHOT" versionScheme := Some("early-semver") organization := "net.sf.ij-plugins" homepage := Some(new URI("https://github.com/ij-plugins/ijp-imagej-launcher").toURL) @@ -16,14 +16,26 @@ enablePlugins(ScalaNativePlugin) logLevel := Level.Info libraryDependencies ++= Seq( - "com.github.scopt" %%% "scopt" % "4.1.0", - "com.lihaoyi" %%% "os-lib" % "0.9.1" + "com.github.scopt" %%% "scopt" % "4.1.0", + "com.lihaoyi" %%% "os-lib" % "0.9.1", + "org.scalatest" %%% "scalatest" % "3.2.16" % Test +) + +scalacOptions ++= Seq( + "-unchecked", + "-deprecation", + "-explain", + "-explain-types", + "-rewrite", + "-source:3.3-migration", +// "-Wvalue-discard", + "-Wunused:all" ) Compile / run / mainClass := Some("ij_plugins.imagej_launcher.Main") // import to add Scala Native options -import scala.scalanative.build._ +import scala.scalanative.build.* // defaults set with common options shown nativeConfig ~= { c => @@ -36,3 +48,8 @@ nativeConfig ~= { c => //nativeConfig ~= { c => // c.withCompileOptions(c.compileOptions ++ Seq("-v")) //} + +// Version info generation from SBT configuration +enablePlugins(BuildInfoPlugin) +buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion) +buildInfoPackage := "ij_plugins.imagej_launcher" diff --git a/extras/IJP-ImageL-Launcher-0.1.0-macosx-arm64.command b/extras/IJP-ImageJ-Launcher-0.1.0-macosx-arm64.command similarity index 51% rename from extras/IJP-ImageL-Launcher-0.1.0-macosx-arm64.command rename to extras/IJP-ImageJ-Launcher-0.1.0-macosx-arm64.command index 4432ceb..ebcfb9e 100755 --- a/extras/IJP-ImageL-Launcher-0.1.0-macosx-arm64.command +++ b/extras/IJP-ImageJ-Launcher-0.1.0-macosx-arm64.command @@ -1,4 +1,4 @@ #!/bin/bash DIR=$(cd "$(dirname "$0")" && pwd -P) echo "$DIR" -"$DIR"/IJP-ImageL-Launcher-0.1.0-macosx-arm64 --debug --ij-dir "$DIR" +"$DIR"/IJP-ImageJ-Launcher-0.1.0-macosx-arm64 --debug --ij-dir "$DIR" diff --git a/notes/v.0.2.0.md b/notes/v.0.2.0.md new file mode 100644 index 0000000..3be1e00 --- /dev/null +++ b/notes/v.0.2.0.md @@ -0,0 +1,5 @@ +### New Features + +* Use launcher location to infer `ij-dir`. #5 +* Session log is saved to `~/.ijp_imagej_launcher.log` to facilitate troubleshooting when not running from command + prompt. The log is reset for each session. #6 diff --git a/project/build.properties b/project/build.properties index ef3d266..6c37b7b 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1,6 @@ -sbt.version = 1.8.3 +# +# Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. +# Author's e-mail: jpsacha at gmail.com +# + +sbt.version = 1.9.0 diff --git a/project/buildinfo.sbt b/project/buildinfo.sbt new file mode 100644 index 0000000..b9ae828 --- /dev/null +++ b/project/buildinfo.sbt @@ -0,0 +1,2 @@ +// https://github.com/sbt/sbt-buildinfo +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index f07f01d..eb2c08d 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,2 @@ -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.12") +// https://github.com/scala-native/scala-native +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.14") diff --git a/src/main/resources/scala-native/argv0.c b/src/main/resources/scala-native/argv0.c new file mode 100644 index 0000000..f073dd8 --- /dev/null +++ b/src/main/resources/scala-native/argv0.c @@ -0,0 +1,40 @@ + +#include + +#if defined(__linux__) +#include +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(_WIN32) +#include +#endif + +size_t path_max() { +#ifdef _WIN32 + return MAX_PATH; +#else + return PATH_MAX + 1; +#endif +} + +void get_exe_path(char* exe_path, size_t size) { + + exe_path[0] = 0; + +#if defined(__linux__) +// char arg1[20]; +// sprintf( arg1, "/proc/%d/exe", getpid() ); +// printf("arg1 = %d\n", arg1); +// readlink( arg1, exepath, PATH_MAX ); + readlink( "/proc/self/exe", exe_path, size ); +#elif defined(__APPLE__) + if (_NSGetExecutablePath(exe_path, &size) != 0) + printf("ERROR: buffer too small; need size %lu\n", size); +#elif defined(_WIN32) + unsigned int len = GetModuleFileNameA(GetModuleHandleA(0x0), exe_path, size); + if (len == 0) // memory not sufficient or general error occurred + printf("ERROR: buffer too small or general error.\n"); +#endif +} \ No newline at end of file diff --git a/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala b/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala new file mode 100644 index 0000000..203d9ca --- /dev/null +++ b/src/main/scala/ij_plugins/imagej_launcher/IJConfigFile.scala @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import os.Path + +object IJConfigFile: + + private val FileName: String = "ImageJ.cfg" + private val MagicHeader: String = "# ImageJ startup properties" + private val MaxHeapMBKey: String = "maxheap.mb" + private val JvmArgsKey: String = "jvmargs" + private val LegacyModeKey: String = "legacy.mode" + private val AssignmentSymbol: String = "=" + private val CommentSymbol: String = "#" + + /** + * Read `ImageJ.cfg` from a specified directory. + * The configuration file must be in ImageJ2 format (with magic header "# ImageJ startup properties"). + * + * @param dir directory where `ImageJ.cfg` is located + * @param logger logger + * @return option containing the configuration file (Right). + * The option is empty if file is not present in input `dir`. + * An error message is returned if file is present but cannot be read without errors (Left). + */ + def readFromDir(dir: Path)(using logger: Logger): Either[String, Option[IJCConfig]] = + logger.debug(s"Reading ImageJ configuration from directory: $dir") + val path = dir / FileName + logger.debug(s" Looking for $FileName in: '$path'") + readFromFile(path) + + private[imagej_launcher] def readFromFile(ijConfigFile: Path)(using logger: Logger): Either[String, Option[IJCConfig]] = + if os.exists(ijConfigFile) then + val lines = os.read.lines(ijConfigFile) + val cfgE = + lines.headOption match + case Some(line) => + if line.trim.startsWith(MagicHeader) then + logger.debug(s" Parsing $FileName") + for + props <- parseProps(lines) + maxHeapMB <- parseLong(props, MaxHeapMBKey) + jvmArgs <- parseString(props, JvmArgsKey) + legacyMode <- parseBoolean(props, LegacyModeKey) + yield Option(IJCConfig(maxHeapMB, jvmArgs, legacyMode)) + else + Left(s"$FileName is invalid, does not contain header: '$MagicHeader'") + case None => + Left(s"$FileName is empty.") + + // Add context to the error message, if there is one + cfgE.left.map(e => s"Failed to read $FileName. $e") + else + logger.debug(s" $FileName not found: $ijConfigFile") + Right(None) + + private[imagej_launcher] def parseProps(lines: Seq[String]): Either[String, Map[String, String]] = + // Parse each line + val lineResults: Seq[Either[String, (String, String)]] = + lines + .zipWithIndex + .filter { case (line, _) => !line.startsWith(CommentSymbol) } + .map { case (line, lineNumber) => + val index = line.indexOf(AssignmentSymbol) + if index > 0 then + val key = line.substring(0, index).trim + if !key.isBlank then + Right(key -> line.substring(index + 1).trim) + else + Left(s"Error in line $lineNumber: key cannot be empty: '$line'") + else if index == 0 then + Left(s"Error in line $lineNumber: line cannot start with an assignment symbol '$AssignmentSymbol': '$line'") + else + Left(s"Error in line $lineNumber: no assignment symbol '$AssignmentSymbol' present: '$line'") + } + + // Separate lines with errors + val errors = lineResults.collect { case e: Left[?, ?] => e } + if errors.isEmpty then + // Convert key->value pairs to a map + val r = + lineResults + .collect { case e: Right[?, ?] => e } + .map(_.value) + .toMap + Right(r) + else + Left(errors.map(_.value).mkString("\n")) + + private def parseLong(props: Map[String, String], key: String): Either[String, Long] = + parseString(props, key).flatMap: s => + s.toLongOption match + case Some(v) => Right(v) + case None => Left(s"Failed to parse '$s' as an integer.") + + private def parseString(props: Map[String, String], key: String): Either[String, String] = + props.get(key) match + case Some(v) => Right(v) + case None => Left(s"No key named '$key'.") + + private def parseBoolean(props: Map[String, String], key: String): Either[String, Boolean] = + parseString(props, key).flatMap: s => + s.toBooleanOption match + case Some(v) => Right(v) + case None => Left(s"Failed to parse '$s' as a boolean.") + + /** Configuration loaded from `ImageJ.cfg`. */ + case class IJCConfig(maxHeapMB: Long, jvmArgs: String, legacyMode: Boolean) + +end IJConfigFile diff --git a/src/main/scala/ij_plugins/imagej_launcher/IJDir.scala b/src/main/scala/ij_plugins/imagej_launcher/IJDir.scala new file mode 100644 index 0000000..e871ea1 --- /dev/null +++ b/src/main/scala/ij_plugins/imagej_launcher/IJDir.scala @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import ij_plugins.imagej_launcher.Main.Config +import os.{FilePath, Path} + +import scala.util.control.NonFatal + +object IJDir: + + /** Name of an ImageJ sub-directory containing jars */ + val jarsDirName = "jars" + + /** Locate ImageJ directory */ + def locate(config: Config)(using logger: Logger): Either[String, Path] = + logger.debug("Looking for ImageJ directory") + + config.ijDir match + case Some(d) => + logger.debug(s" Considering provided ij-dir: '$d'") + asPath(d.getPath).flatMap: p => + logger.debug(s" '$p'") + if isIJDir(p) then Right(p) + else Left(s"ij-dir is not an ImageJ directory [$p]") + case None => + logger.debug(" Considering application directory") + val appPath = Native.applicationPath() + logger.debug(s" Application directory: '$appPath'") + inferIJDir(appPath) match + case Some(p) => + Right(p) + case None => + logger.debug(" Application directory is not an ImageJ directory") + logger.debug(" Considering current working directory") + val cwd = os.pwd + logger.debug(s" Current working directory: '$cwd'") + inferIJDir(cwd) match + case Some(p) => + Right(p) + case None => + logger.debug(" Current working directory is not an ImageJ directory.") + Left("Cannot locate ImageJ directory.") + + private def inferIJDir(path: Path): Option[Path] = + if isIJDir(path) then + Option(path) + else if path.endsWith(os.rel / "Contents" / "MacOS") then + Option(path / os.up / os.up) + else + None + + private def isIJDir(path: Path): Boolean = + os.exists(path) && + os.isDir(path) && + os.list(path).exists(f => f.last == jarsDirName && os.isDir(f)) + + private def asPath(filePath: String): Either[String, Path] = + if filePath.trim.startsWith("~") then + try + Right(Path.expandUser(filePath)) + catch + case NonFatal(ex) => + Left(s"Not an absolute path: '$filePath' [${ex.getMessage}]") + else + FilePath(filePath) match + case p: Path => Right(p) + case _ => Left(s"Not an absolute path: '$filePath'") + +end IJDir diff --git a/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala b/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala index 2c8d654..694d183 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Launcher.scala @@ -5,6 +5,7 @@ package ij_plugins.imagej_launcher +import ij_plugins.imagej_launcher.IJDir.jarsDirName import ij_plugins.imagej_launcher.Launcher.javaExeFileName import ij_plugins.imagej_launcher.Main.Config import os.Path @@ -12,9 +13,7 @@ import os.Path import java.io.File import java.lang.ProcessBuilder.Redirect -class Launcher(logger: Logger): - - private val jarsDirName = "jars" +class Launcher(using logger: Logger): def run(config: Config): Unit = prepareLaunch(config) match @@ -29,33 +28,16 @@ class Launcher(logger: Logger): private def prepareLaunch(config: Config): Either[String, Seq[String]] = for - ijDir <- locateIJDir(config) - _ <- Updater.update(Path(ijDir), config.dryRun, logger) - launcherJar <- findImageJLauncherJar(ijDir) - javaExe <- locateJavaExecutable(config, ijDir) + ijDir <- IJDir.locate(config) + _ <- Updater.update(ijDir, config.dryRun) + ijConfig <- IJConfigFile.readFromDir(ijDir) + launcherJar <- findImageJLauncherJar(ijDir.toIO) + javaExe <- locateJavaExecutable(config, ijDir.toIO) systemType <- determineSystemType() yield val maxMemoryMB = determineMaxMemoryMB() logger.info(s"Max memory to use: ${maxMemoryMB}MB") - buildCommandLine(ijDir, javaExe, launcherJar, systemType, maxMemoryMB) - - private def locateIJDir(config: Config): Either[String, File] = - logger.debug("Looking for ImageJ directory") - - val dir = config.ijDir match - case Some(d) => - logger.debug(" Considering provided ij-dir") - d - case None => - logger.debug(" Considering current directory") - new File(".").getCanonicalFile - - dir.listFiles().find(f => f.getName == jarsDirName && f.isDirectory) match - case Some(_) => - logger.info(s" ImageJ directory set to: '$dir'") - Right(dir) - case None => - Left(s"Cannot locate ImageJ directory. No subdirectory '$jarsDirName' in '$dir''") + buildCommandLine(ijDir.toIO, javaExe, launcherJar, systemType, maxMemoryMB) private def findImageJLauncherJar(ijDir: File): Either[String, File] = logger.debug("Looking for 'imagej-launcher*.jar'") @@ -119,7 +101,7 @@ class Launcher(logger: Logger): p.toIO.getName == javaExeFileName && p.toIO.getParentFile.getName == "bin" ) - logger.debug(" Candidates: " + c2.mkString(", ")) + logger.debug(" Candidates: " + c2.mkString(", ")) c2.map(_.toIO.getParentFile.getParentFile) .headOption else @@ -197,6 +179,7 @@ class Launcher(logger: Logger): "plugins", "net.imagej.Main" ) + end buildCommandLine private def launch(command: Seq[String]): Unit = logger.debug("launchImageJ ...") diff --git a/src/main/scala/ij_plugins/imagej_launcher/Logger.scala b/src/main/scala/ij_plugins/imagej_launcher/Logger.scala index baa5390..9963f40 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Logger.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Logger.scala @@ -5,20 +5,60 @@ package ij_plugins.imagej_launcher -import ij_plugins.imagej_launcher.Logger.Level +import ij_plugins.imagej_launcher.Logger.{Level, logToFile} +import os.Path + +import java.nio.file.{Files, StandardOpenOption} +import scala.util.control.NonFatal class Logger(val level: Level = Level.Info): + def debug(msg: String): Unit = pprint(Level.Debug, msg) def info(msg: String): Unit = pprint(Level.Info, msg) def error(msg: String): Unit = pprint(Level.Error, msg) private def pprint(l: Level, message: String): Unit = + val m = f"${l.name}%-5s: $message" if l.level <= level.level then println(f"${l.name}%-5s: $message") + logToFile(m) object Logger: + + private val LogFile: Path = os.home / ".ijp_imagej_launcher.log" + private var logFileReset: Boolean = true + enum Level(val level: Int, val name: String): case Off extends Level(0, "OFF") case Error extends Level(200, "ERROR") case Info extends Level(400, "INFO") case Debug extends Level(500, "DEBUG") case All extends Level(Int.MaxValue, "DEBUG") + + private def logToFile(message: String): Unit = + try + if logFileReset && os.exists(LogFile) then + val _ = os.remove(LogFile) + logFileReset = false + + val data = readableTimeStamp() + ": " + message + "\n" + val lines = data.getBytes() + val _ = Files.write( + LogFile.toNIO, + lines, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + StandardOpenOption.WRITE + ) + catch + case NonFatal(_) => + // Ignore exceptions + + private def readableTimeStamp(): String = + // Simple implementation returns GMT time (no date) + // Use custom code as DateTimeFormatter is not available in Scala Native 0.4.14 + val t = System.currentTimeMillis() + val sss = t % 1000 + val ss = (t / 1000L).toInt % 60 + val mm = (t / (1000L * 60L)).toInt % 60 + val hh = (t / (1000L * 60L * 60L)).toInt % 24 + f"$hh%02d-$mm%02d-$ss%02d_$sss%03dZ" diff --git a/src/main/scala/ij_plugins/imagej_launcher/Main.scala b/src/main/scala/ij_plugins/imagej_launcher/Main.scala index 355f25c..d2afaa8 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Main.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Main.scala @@ -5,15 +5,14 @@ package ij_plugins.imagej_launcher -import scopt.{DefaultOEffectSetup, OParser} +import scopt.OParser import java.io.File object Main: - private var logger = new Logger() - private val AppName = "ijp-imagej-launcher" - // private val AppVersion = s"${Version.version} [${Version.buildTimeStr}]" - private val AppVersion = s"0.1.0" + private var logger = new Logger() + private val AppName = "IJP-ImageJ-Launcher" + private val AppVersion = BuildInfo.version private val VersionMessage = s"v.$AppVersion" private val AppDescription = """Native launcher for ImageJ2 @@ -29,6 +28,7 @@ object Main: case None => private def parseCommandLine(args: Array[String]): Option[Config] = + logger.debug("Command line: " + args.map("'" + _ + "'").mkString(", ")) val builder = OParser.builder[Config] val parser1 = import builder.* @@ -69,7 +69,7 @@ object Main: private def setupLogger(logLevel: Logger.Level): Unit = logger = new Logger(logLevel) - private def runLauncher(config: Config): Unit = new Launcher(logger).run(config) + private def runLauncher(config: Config): Unit = new Launcher(using logger).run(config) case class Config( logLevel: Logger.Level = Logger.Level.Error, diff --git a/src/main/scala/ij_plugins/imagej_launcher/Native.scala b/src/main/scala/ij_plugins/imagej_launcher/Native.scala index 2e847c4..3c82706 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Native.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Native.scala @@ -1,9 +1,29 @@ package ij_plugins.imagej_launcher -import scala.scalanative.unsafe.extern +import os.{Path, up} + +import scala.scalanative.unsafe +import scala.scalanative.unsafe.* object Native: + def applicationPath(): Path = + val maxPath: CSize = argv0.path_max() + // use Zone to manage native memory + val exePath = + Zone { implicit z => + val buffer: CString = alloc[CChar](maxPath) + argv0.get_exe_path(buffer, maxPath) + unsafe.fromCString(buffer) + } + Path(exePath) / up + @extern object mem: def determineTotalSystemMemory(): Long = extern + + @extern + private object argv0: + def path_max(): CSize = extern + + def get_exe_path(exe_path: CString, size: CSize): Unit = extern diff --git a/src/main/scala/ij_plugins/imagej_launcher/Updater.scala b/src/main/scala/ij_plugins/imagej_launcher/Updater.scala index d73e70c..c855f82 100644 --- a/src/main/scala/ij_plugins/imagej_launcher/Updater.scala +++ b/src/main/scala/ij_plugins/imagej_launcher/Updater.scala @@ -1,6 +1,11 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + package ij_plugins.imagej_launcher -import os.{Path, RelPath} +import os.Path import scala.util.control.NonFatal @@ -13,7 +18,7 @@ object Updater: * @param logger configured logger * @return Number of files processed or an error message. */ - def update(ijDir: Path, dryRun: Boolean, logger: Logger): Either[String, Long] = + def update(ijDir: Path, dryRun: Boolean)(using logger: Logger): Either[String, Long] = try val updateDir = ijDir / "update" // Count used only for debug info @@ -23,8 +28,7 @@ object Updater: os.walk(updateDir) .filter(os.isFile) .foreach: src => -// val relativeDir = src.relativeTo(updateDir) - val dst = ijDir / relativeTo(src, updateDir) + val dst = ijDir / src.relativeTo(updateDir) if os.size(src) == 0 then logger.debug(s"remove: $dst") if !dryRun then os.remove(dst) @@ -35,7 +39,7 @@ object Updater: if !dryRun then os.move(src, dst, replaceExisting = true, createFolders = true) count += 1 logger.debug(s"Delete update directory: $updateDir") - if !dryRun then deleteEmptyDirs(updateDir, logger) + if !dryRun then deleteEmptyDirs(updateDir) Right(count) else logger.info("No update found") @@ -45,30 +49,11 @@ object Updater: ex.printStackTrace() Left(s"Failed to perform update: ${ex.getMessage} - ${ex.getClass.getSimpleName}") - private def relativeTo(src: Path, base: Path): RelPath = - // This does what src.relativeTo(base) should do - // Problems is in the native code on Windows, - // os.Path#relativeTo creates relative path by adding `../` at the beginning of the absolute path, - // co you may get `../C:\a\b` which leads to a exception soon after. - // The issue is with Scala Native implementation of java.nio.file.Path#relativize on Windows, - // See https://github.com/scala-native/scala-native/issues/3293 - - // This implementation is very limited but sufficient for our use. - // It assumes specific relation between src and base. - - val srcStr = src.toString - val baseStr = base.toString - require(baseStr.nonEmpty) - require(srcStr.startsWith(baseStr)) - - val relStr = srcStr.drop(baseStr.length + 1) - RelPath(relStr) - - private def deleteEmptyDirs(dir: Path, logger: Logger): Unit = + private def deleteEmptyDirs(dir: Path)(using logger: Logger): Unit = logger.debug(s"Cleaning directory: $dir") os.list(dir) .filter(os.isDir) - .foreach(p => deleteEmptyDirs(p, logger)) + .foreach(p => deleteEmptyDirs(p)) if os.list(dir).isEmpty then logger.debug(s"Removing empty dir: $dir") diff --git a/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala b/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala new file mode 100644 index 0000000..f0260fe --- /dev/null +++ b/src/test/scala/ij_plugins/imagej_launcher/IJConfigFileTest.scala @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import org.scalatest.flatspec.AnyFlatSpec + +class IJConfigFileTest extends AnyFlatSpec: + + given logger:Logger = new Logger(Logger.Level.All) + + "IJConfigFile" should "read valid config files" in: + val path = os.pwd / "test" / "data" / "config_valid" / "ImageJ.cfg" + + IJConfigFile.readFromFile(path) match + case Right(cfg) => + logger.debug(cfg.toString) + assert(cfg.nonEmpty) + case Left(err) => + logger.debug(s"Unexpected error: $err") + fail() + + it should "fail reading invalid config files" in: + val path = os.pwd / "test" / "data" / "config_invalid" / "ImageJ.cfg" + + IJConfigFile.readFromFile(path) match + case Right(cfg) => + logger.debug(cfg.toString) + fail(s"Expected to return errors when reading config, but read config as: $cfg") + case Left(err) => + logger.debug(s"Expected error\n:$err") diff --git a/src/test/scala/ij_plugins/imagej_launcher/PathSpec.scala b/src/test/scala/ij_plugins/imagej_launcher/PathSpec.scala new file mode 100644 index 0000000..36845bb --- /dev/null +++ b/src/test/scala/ij_plugins/imagej_launcher/PathSpec.scala @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2000-2023 Jarek Sacha. All Rights Reserved. + * Author's e-mail: jpsacha at gmail.com + */ + +package ij_plugins.imagej_launcher + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should + +import java.nio.file.FileSystems +import scala.scalanative.runtime.Platform.isWindows + +class PathSpec extends AnyFlatSpec with should.Matchers: + + "A Path" should "should `relativize` jars on Windows (Scala Native #3293)" in { + if isWindows() then + // This to test that fix for Scala Native #3293 implemented, it should be part of Scala Native 0.4.13 + // See https://github.com/scala-native/scala-native/issues/3293 + + // val src = Path.of("C:\\a\\b\\c.jar") + val src = FileSystems.getDefault.getPath("C:\\a\\b\\c.jar") + // val base = Path.of("C:\\a") + val base = FileSystems.getDefault.getPath("C:\\a") + + val rel = base.relativize(src) + rel.toString should be("b\\c.jar") + } diff --git a/src/test/scala/ij_plugins/imagej_launcher/UpdaterDemo.scala b/src/test/scala/ij_plugins/imagej_launcher/UpdaterDemo.scala index 6e7f24a..49f674a 100644 --- a/src/test/scala/ij_plugins/imagej_launcher/UpdaterDemo.scala +++ b/src/test/scala/ij_plugins/imagej_launcher/UpdaterDemo.scala @@ -4,6 +4,7 @@ import os.Path @main def updaterDemo(ijDir: String): Unit = - Updater.update(Path(ijDir), dryRun = false, new Logger(Logger.Level.Debug)) match + given logger: Logger = new Logger(Logger.Level.Debug) + Updater.update(Path(ijDir), dryRun = false) match case Right(count) => println(s"Processed $count files") case Left(error) => println(s"Failed with error: $error") diff --git a/test/data/config_invalid/ImageJ.cfg b/test/data/config_invalid/ImageJ.cfg new file mode 100644 index 0000000..9fc162e --- /dev/null +++ b/test/data/config_invalid/ImageJ.cfg @@ -0,0 +1,4 @@ +# ImageJ startup properties +maxheap.mb = 1024m +jvmargs = -XX:+HeapDumpOnOutOfMemoryError -Xincgc +legacy.mode = false \ No newline at end of file diff --git a/test/data/config_valid/ImageJ.cfg b/test/data/config_valid/ImageJ.cfg new file mode 100644 index 0000000..5a40166 --- /dev/null +++ b/test/data/config_valid/ImageJ.cfg @@ -0,0 +1,4 @@ +# ImageJ startup properties +maxheap.mb = 1024 +jvmargs = -XX:+HeapDumpOnOutOfMemoryError -Xincgc +legacy.mode = false \ No newline at end of file