From 0fc7e3f6a06f40c54c5c4f48026fc5598a6193a9 Mon Sep 17 00:00:00 2001 From: Steve Hannah Date: Fri, 2 Jan 2026 12:07:51 -0800 Subject: [PATCH 1/3] feat: Add support for commands implements property Allows commands to act as updaters, launchers, and service controllers. --- .../cli/AbstractUnixCliCommandInstaller.java | 7 +- .../cli/LinuxCliCommandInstaller.java | 59 +++++- .../installer/cli/MacCliCommandInstaller.java | 100 +++++++++- .../cli/WindowsCliCommandInstaller.java | 149 +++++++++++++-- ...ractUnixCliCommandInstallerBinDirTest.java | 2 +- .../cli/MacCliCommandInstallerBinDirTest.java | 2 +- rfc/cli-commands-in-installer.md | 172 +++++++++++++++++- .../weblite/jdeploy/models/CommandSpec.java | 36 +++- .../jdeploy/models/CommandSpecParser.java | 58 +++++- 9 files changed, 540 insertions(+), 45 deletions(-) diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java index e937bf56..72f18786 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java @@ -358,7 +358,7 @@ protected List installCommandScripts(File launcherPath, List } try { - writeCommandScript(scriptPath, launcherPath.getAbsolutePath(), cmdName, command.getArgs()); + writeCommandScript(scriptPath, launcherPath.getAbsolutePath(), command); System.out.println("Created command-line script: " + scriptPath.getAbsolutePath()); createdFiles.add(scriptPath); } catch (IOException ioe) { @@ -375,11 +375,10 @@ protected List installCommandScripts(File launcherPath, List * * @param scriptPath the path where the script should be created * @param launcherPath the path to the launcher executable - * @param commandName the command name to invoke - * @param args additional command-line arguments (if any) + * @param command the command specification including name, args, and implementations * @throws IOException if the script cannot be created */ - protected abstract void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException; + protected abstract void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException; /** * Computes a user-friendly display path (using ~ for home directory). diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java index 93b954a2..fa26a3b0 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java @@ -163,8 +163,8 @@ public File installLauncher(File launcherPath, String commandName, InstallationS } @Override - protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException { - String content = generateContent(launcherPath, commandName); + protected void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException { + String content = generateContent(launcherPath, command); try (FileOutputStream fos = new FileOutputStream(scriptPath)) { fos.write(content.getBytes(StandardCharsets.UTF_8)); } @@ -175,15 +175,64 @@ protected void writeCommandScript(File scriptPath, String launcherPath, String c /** * Generate the content of a POSIX shell script that exec's the given launcher with * the configured command name and forwards user-supplied args. + * Handles special implementations: updater, launcher, service_controller. * * @param launcherPath Absolute path to the CLI-capable launcher binary. - * @param commandName The command name to pass as --jdeploy:command=. + * @param command The command specification including implementations. * @return Script content (including shebang and trailing newline). */ - private static String generateContent(String launcherPath, String commandName) { + private static String generateContent(String launcherPath, CommandSpec command) { StringBuilder sb = new StringBuilder(); sb.append("#!/bin/sh\n"); - sb.append("exec \"").append(escapeDoubleQuotes(launcherPath)).append("\" ").append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName).append(" -- \"$@\"\n"); + + String escapedLauncher = escapeDoubleQuotes(launcherPath); + String commandName = command.getName(); + List implementations = command.getImplementations(); + + // Check for launcher implementation (highest priority, mutually exclusive) + if (implementations.contains("launcher")) { + // For launcher: just execute the binary directly, passing all args + sb.append("exec \"").append(escapedLauncher).append("\" \"$@\"\n"); + return sb.toString(); + } + + boolean hasUpdater = implementations.contains("updater"); + boolean hasServiceController = implementations.contains("service_controller"); + + if (hasUpdater || hasServiceController) { + // Generate conditional script with checks + + // Check for updater: single "update" argument + if (hasUpdater) { + sb.append("# Check if single argument is \"update\"\n"); + sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n"); + sb.append(" exec \"").append(escapedLauncher).append("\" --jdeploy:update\n"); + sb.append("fi\n\n"); + } + + // Check for service_controller: first argument is "service" + if (hasServiceController) { + sb.append("# Check if first argument is \"service\"\n"); + sb.append("if [ \"$1\" = \"service\" ]; then\n"); + sb.append(" shift\n"); + sb.append(" exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" --jdeploy:service \"$@\"\n"); + sb.append("fi\n\n"); + } + + // Default: normal command + sb.append("# Default: normal command\n"); + sb.append("exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } else { + // Standard command (no special implementations) + sb.append("exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } + return sb.toString(); } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java index 4950b4cf..3ffe6aea 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java @@ -145,23 +145,103 @@ public List installCommands(File launcherPath, List commands, } @Override - protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException { - StringBuilder script = new StringBuilder(); - script.append("#!/bin/bash\n"); - script.append("\"").append(escapeDoubleQuotes(launcherPath)).append("\" "); - script.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); - script.append(" -- \"$@\"\n"); - + protected void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException { + String content = generateContent(launcherPath, command); try (FileOutputStream fos = new FileOutputStream(scriptPath)) { - fos.write(script.toString().getBytes(StandardCharsets.UTF_8)); + fos.write(content.getBytes(StandardCharsets.UTF_8)); } - scriptPath.setExecutable(true, false); } + /** + * Generate the content of a bash script that executes the given launcher with + * the configured command name and forwards user-supplied args. + * Handles special implementations: updater, launcher, service_controller. + * + * @param launcherPath Absolute path to the CLI-capable launcher binary. + * @param command The command specification including implementations. + * @return Script content (including shebang and trailing newline). + */ + private static String generateContent(String launcherPath, CommandSpec command) { + StringBuilder sb = new StringBuilder(); + sb.append("#!/bin/bash\n"); + + String escapedLauncher = escapeDoubleQuotes(launcherPath); + String commandName = command.getName(); + List implementations = command.getImplementations(); + + // Check for launcher implementation (highest priority, mutually exclusive) + if (implementations.contains("launcher")) { + // For launcher on macOS: use 'open' command to launch the .app bundle + // Extract app bundle path if launcherPath is inside a .app + String appPath = extractAppBundlePath(launcherPath); + if (appPath != null) { + sb.append("exec open -a \"").append(escapeDoubleQuotes(appPath)).append("\" \"$@\"\n"); + } else { + // Fallback: execute binary directly + sb.append("exec \"").append(escapedLauncher).append("\" \"$@\"\n"); + } + return sb.toString(); + } + + boolean hasUpdater = implementations.contains("updater"); + boolean hasServiceController = implementations.contains("service_controller"); + + if (hasUpdater || hasServiceController) { + // Generate conditional script with checks + + // Check for updater: single "update" argument + if (hasUpdater) { + sb.append("# Check if single argument is \"update\"\n"); + sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n"); + sb.append(" exec \"").append(escapedLauncher).append("\" --jdeploy:update\n"); + sb.append("fi\n\n"); + } + + // Check for service_controller: first argument is "service" + if (hasServiceController) { + sb.append("# Check if first argument is \"service\"\n"); + sb.append("if [ \"$1\" = \"service\" ]; then\n"); + sb.append(" shift\n"); + sb.append(" exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" --jdeploy:service \"$@\"\n"); + sb.append("fi\n\n"); + } + + // Default: normal command + sb.append("# Default: normal command\n"); + sb.append("exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } else { + // Standard command (no special implementations) + sb.append("exec \"").append(escapedLauncher).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } + + return sb.toString(); + } + + /** + * Extracts the .app bundle path from a launcher path inside a .app bundle. + * For example: /Applications/MyApp.app/Contents/MacOS/Client4JLauncher-cli -> /Applications/MyApp.app + * + * @param launcherPath the launcher path + * @return the .app bundle path, or null if not inside a .app bundle + */ + private static String extractAppBundlePath(String launcherPath) { + int appIndex = launcherPath.indexOf(".app/"); + if (appIndex > 0) { + return launcherPath.substring(0, appIndex + 4); // Include .app + } + return null; + } + /** * Escapes double quotes in a string for shell script usage. - * + * * @param s the string to escape * @return the escaped string */ diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java index 90dbcf7e..bf38fa3b 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java @@ -298,15 +298,15 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li // Check for collision with existing wrapper (check .cmd as the primary indicator) if (cmdWrapper.exists()) { String existingLauncherPath = extractLauncherPathFromCmdFile(cmdWrapper); - + if (existingLauncherPath != null && !existingLauncherPath.equals(launcherPath.getAbsolutePath())) { // Different app owns this command - invoke collision handler CollisionAction action = collisionHandler.handleCollision( - name, - existingLauncherPath, + name, + existingLauncherPath, launcherPath.getAbsolutePath() ); - + if (action == CollisionAction.SKIP) { System.out.println("Skipping command '" + name + "' - already owned by another app"); continue; @@ -321,21 +321,14 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li shWrapper.delete(); } - // 1. Windows batch wrapper (.cmd): invoke the launcher with --jdeploy:command= and forward all args - // We use \r\n for Windows batch files - String cmdContent = "@echo off\r\n\"" + launcherPath.getAbsolutePath() + "\" " + - CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX + name + " -- %*\r\n"; + // Generate wrapper content based on implementations + String cmdContent = generateCmdContent(launcherPath, cs); + String shContent = generateShellContent(launcherPath, cs); FileUtils.writeStringToFile(cmdWrapper, cmdContent, "UTF-8"); cmdWrapper.setExecutable(true, false); created.add(cmdWrapper); - // 2. Extensionless shell script for Git Bash / MSYS2 - // We use \n for shell scripts - String msysLauncherPath = convertToMsysPath(launcherPath); - String shContent = "#!/bin/sh\n\"" + msysLauncherPath + "\" " + - CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX + name + " -- \"$@\"\n"; - FileUtils.writeStringToFile(shWrapper, shContent, "UTF-8"); shWrapper.setExecutable(true, false); } @@ -343,10 +336,136 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li return created; } + /** + * Generates Windows batch (.cmd) wrapper content based on command implementations. + * + * @param launcherPath the path to the launcher executable + * @param command the command specification including implementations + * @return the .cmd file content with \r\n line endings + */ + private String generateCmdContent(File launcherPath, CommandSpec command) { + StringBuilder sb = new StringBuilder(); + sb.append("@echo off\r\n"); + + String launcherPathStr = launcherPath.getAbsolutePath(); + String commandName = command.getName(); + List implementations = command.getImplementations(); + + // Check for launcher implementation (highest priority, mutually exclusive) + if (implementations.contains("launcher")) { + // For launcher: just execute the binary directly with all args + sb.append("\"").append(launcherPathStr).append("\" %*\r\n"); + return sb.toString(); + } + + boolean hasUpdater = implementations.contains("updater"); + boolean hasServiceController = implementations.contains("service_controller"); + + if (hasUpdater || hasServiceController) { + // Generate conditional batch script + + // Check for updater: single "update" argument + if (hasUpdater) { + sb.append("REM Check if single argument is \"update\"\r\n"); + sb.append("if \"%~1\"==\"update\" if \"%~2\"==\"\" (\r\n"); + sb.append(" \"").append(launcherPathStr).append("\" --jdeploy:update\r\n"); + sb.append(" goto :eof\r\n"); + sb.append(")\r\n\r\n"); + } + + // Check for service_controller: first argument is "service" + if (hasServiceController) { + sb.append("REM Check if first argument is \"service\"\r\n"); + sb.append("if \"%~1\"==\"service\" (\r\n"); + sb.append(" shift\r\n"); + sb.append(" \"").append(launcherPathStr).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" --jdeploy:service %*\r\n"); + sb.append(" goto :eof\r\n"); + sb.append(")\r\n\r\n"); + } + + // Default: normal command + sb.append("REM Default: normal command\r\n"); + sb.append("\"").append(launcherPathStr).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- %*\r\n"); + } else { + // Standard command (no special implementations) + sb.append("\"").append(launcherPathStr).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- %*\r\n"); + } + + return sb.toString(); + } + + /** + * Generates shell script wrapper content for Git Bash / MSYS2 based on command implementations. + * + * @param launcherPath the path to the launcher executable + * @param command the command specification including implementations + * @return the shell script content with \n line endings + */ + private String generateShellContent(File launcherPath, CommandSpec command) { + StringBuilder sb = new StringBuilder(); + sb.append("#!/bin/sh\n"); + + String msysLauncherPath = convertToMsysPath(launcherPath); + String commandName = command.getName(); + List implementations = command.getImplementations(); + + // Check for launcher implementation (highest priority, mutually exclusive) + if (implementations.contains("launcher")) { + // For launcher: just execute the binary directly with all args + sb.append("exec \"").append(msysLauncherPath).append("\" \"$@\"\n"); + return sb.toString(); + } + + boolean hasUpdater = implementations.contains("updater"); + boolean hasServiceController = implementations.contains("service_controller"); + + if (hasUpdater || hasServiceController) { + // Generate conditional shell script + + // Check for updater: single "update" argument + if (hasUpdater) { + sb.append("# Check if single argument is \"update\"\n"); + sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n"); + sb.append(" exec \"").append(msysLauncherPath).append("\" --jdeploy:update\n"); + sb.append("fi\n\n"); + } + + // Check for service_controller: first argument is "service" + if (hasServiceController) { + sb.append("# Check if first argument is \"service\"\n"); + sb.append("if [ \"$1\" = \"service\" ]; then\n"); + sb.append(" shift\n"); + sb.append(" exec \"").append(msysLauncherPath).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" --jdeploy:service \"$@\"\n"); + sb.append("fi\n\n"); + } + + // Default: normal command + sb.append("# Default: normal command\n"); + sb.append("exec \"").append(msysLauncherPath).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } else { + // Standard command (no special implementations) + sb.append("exec \"").append(msysLauncherPath).append("\" "); + sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName); + sb.append(" -- \"$@\"\n"); + } + + return sb.toString(); + } + /** * Extracts the launcher path from an existing .cmd wrapper file. * Parses the file looking for the pattern: "path\to\launcher.exe" --jdeploy:command= - * + * * @param cmdFile the path to the existing .cmd file * @return the launcher path if found, or null if parsing fails */ diff --git a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java index ad2f830c..a2b7b8c2 100644 --- a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java +++ b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java @@ -24,7 +24,7 @@ public void setUp() { // Create a concrete implementation for testing installer = new AbstractUnixCliCommandInstaller() { @Override - protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, java.util.List args) throws java.io.IOException { + protected void writeCommandScript(File scriptPath, String launcherPath, ca.weblite.jdeploy.models.CommandSpec command) throws java.io.IOException { // No-op for this test } diff --git a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java index cbb9e9f8..284861c7 100644 --- a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java +++ b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java @@ -191,7 +191,7 @@ public void testMacInstallerDelegatesCorrectly() { // by comparing behavior with direct instantiation of abstract class AbstractUnixCliCommandInstaller abstractInstaller = new AbstractUnixCliCommandInstaller() { @Override - protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, java.util.List args) throws java.io.IOException { + protected void writeCommandScript(File scriptPath, String launcherPath, ca.weblite.jdeploy.models.CommandSpec command) throws java.io.IOException { // No-op for this test } diff --git a/rfc/cli-commands-in-installer.md b/rfc/cli-commands-in-installer.md index 6fffce1c..7c1ffb75 100644 --- a/rfc/cli-commands-in-installer.md +++ b/rfc/cli-commands-in-installer.md @@ -40,6 +40,7 @@ Add an optional `jdeploy.commands` object to `package.json` where command names - `args` (array of strings, optional): extra static arguments to pass to the launcher when this command is invoked (commonly JVM properties like `-D...` or app-specific flags). - `description` (string, optional): a short description of what the command does, displayed in the installer UI to help users understand the command's purpose. +- `implements` (array of strings, optional): specifies special behaviors for this command. Each string must be one of: `"updater"`, `"service_controller"`, `"launcher"`. See "Command Implementations" section below for detailed behavior specifications. Example: ```json @@ -49,14 +50,23 @@ Example: "args": [ "-Dmy.system.prop=foo", "--my-app-arg=bar" - ] + ], + "implements": ["updater"] }, "myapp-admin": { "args": [ "--mode=admin" ] + }, + "myapp": { + "description": "Open files in MyApp", + "implements": ["launcher"] + }, + "myappctl": { + "description": "Control MyApp services", + "implements": ["service_controller", "updater"] } - ] + } } ``` @@ -67,6 +77,7 @@ Validation rules: - This prevents attempts to place commands into arbitrary paths or overwrite paths by containing `../`. - `args` if present MUST be an array of strings. Empty arrays are allowed. - `description` if present MUST be a string. It is used to display context about the command in the installer UI. The description should be concise (recommended: under 80 characters) and provide a brief explanation of what the command does. +- `implements` if present MUST be an array of strings. Each string MUST be one of: `"updater"`, `"service_controller"`, `"launcher"`. Empty arrays are allowed. Invalid values should be rejected at build-time. - The `commands` object itself may be empty (`"commands": {}` is valid and results in no CLI command wrappers being installed). - The installer should validate this schema at bundle/installer build-time and reject invalid configs (or warn + skip installing bad entries). @@ -104,6 +115,60 @@ If the launcher receives `--jdeploy:command` it must stop any GUI bootstrap earl NOTE: The launcher is a black box to this project. We include these specifications here to ensure the installer and packaging work correctly with the launcher behavior. +## Command Implementations + +Commands may declare special behaviors via the optional `implements` array. Each implementation type modifies how the command wrapper script behaves: + +### Updater Implementation + +When a command includes `"updater"` in its `implements` array, the wrapper script checks for a single `update` argument: + +- **If called with exactly one argument `update`**: The wrapper calls the app's CLI binary with `--jdeploy:update` (omitting the usual `--jdeploy:command={commandName}`). + - Example: `myapp-cli update` → `launcher --jdeploy:update` +- **If called with no arguments or with additional arguments**: The wrapper behaves like a normal command, passing `--jdeploy:command={commandName}`. + - Example: `myapp-cli` → `launcher --jdeploy:command=myapp-cli --` + - Example: `myapp-cli foo bar` → `launcher --jdeploy:command=myapp-cli -- foo bar` + +**Use case**: Allows users to trigger application updates from the command line using a dedicated CLI command. + +### Launcher Implementation + +When a command includes `"launcher"` in its `implements` array, the wrapper script launches the desktop application instead of invoking CLI mode: + +- **Behavior**: All arguments are passed through to the binary and are assumed to be URLs or file paths to open. +- **Platform-specific handling**: + - **macOS**: Use the `open` command to launch the `.app` bundle with arguments. + - Example: `myapp file.txt` → `open -a MyApp.app file.txt` + - **Windows/Linux**: Call the app binary script directly with arguments. + - Example: `myapp file.txt` → `launcher file.txt` +- **No `--jdeploy:command` flag**: The launcher implementation does NOT pass the `--jdeploy:command={commandName}` flag. + +**Use case**: Provides a convenient CLI shortcut to open files or URLs in the GUI application. + +### Service Controller Implementation + +When a command includes `"service_controller"` in its `implements` array, the wrapper script intercepts calls where the first argument is `service`: + +- **If the first argument is `service`**: The wrapper calls the app's CLI binary with `--jdeploy:command={commandName}` and `--jdeploy:service` followed by the remaining arguments. + - Example: `myappctl service start` → `launcher --jdeploy:command=myappctl --jdeploy:service start` + - Example: `myappctl service stop` → `launcher --jdeploy:command=myappctl --jdeploy:service stop` + - Example: `myappctl service status` → `launcher --jdeploy:command=myappctl --jdeploy:service status` +- **If the first argument is NOT `service`**: The wrapper processes the call like a normal command. + - Example: `myappctl version` → `launcher --jdeploy:command=myappctl -- version` + +**Use case**: Provides a standardized interface for controlling background services or daemons managed by the application. + +### Multiple Implementations + +A command may include multiple implementation types in its `implements` array. The wrapper script MUST check for special behaviors in the following order: + +1. Check for `launcher` implementation (if present, launch desktop app and exit) +2. Check for `updater` implementation and single `update` argument (if match, call with `--jdeploy:update` and exit) +3. Check for `service_controller` implementation and first argument `service` (if match, call with `--jdeploy:service` and exit) +4. If no special behavior matches, invoke as a normal command with `--jdeploy:command={commandName}` + +**Note**: In practice, `launcher` is mutually exclusive with other implementations since it does not use `--jdeploy:command`. Combining `updater` and `service_controller` is valid and allows a single command to handle both updates and service control. + ## Platform behaviors High-level goal: CLI command scripts/wrappers are installed per-user, in a per-app, architecture-specific directory that is added to the user's PATH. @@ -277,7 +342,9 @@ Fields: ## Example artifacts -Linux shell script (example): +### Standard Command (no special implementations) + +Linux shell script: ```sh #!/usr/bin/env sh # Installed at: ~/.jdeploy/bin-x64/myapp/myapp-cli @@ -287,7 +354,7 @@ exec "/home/alice/.jdeploy/apps/myapp/my-app" \ "$@" ``` -macOS script (example): +macOS script: ```sh #!/usr/bin/env sh # Installed at: ~/.jdeploy/bin-arm64/myapp/myapp-cli @@ -297,7 +364,7 @@ exec "/Users/alice/Applications/MyApp.app/Contents/MacOS/Client4JLauncher-cli" \ "$@" ``` -Windows `.cmd` wrapper (example): +Windows `.cmd` wrapper: ```cmd @echo off REM Installed at: %USERPROFILE%\.jdeploy\bin-x64\myapp\myapp-cli.cmd @@ -305,6 +372,101 @@ set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp-cli.exe" "%LAUNCHER%" --jdeploy:command=myapp-cli -- %* ``` +### Command with Updater Implementation + +Linux/macOS script with `"implements": ["updater"]`: +```sh +#!/usr/bin/env sh +# Check if single argument is "update" +if [ "$#" -eq 1 ] && [ "$1" = "update" ]; then + exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:update +else + exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myapp-cli -- "$@" +fi +``` + +Windows `.cmd` wrapper with `"implements": ["updater"]`: +```cmd +@echo off +REM Check if single argument is "update" +if "%~1"=="update" if "%~2"=="" ( + "%LAUNCHER%" --jdeploy:update +) else ( + "%LAUNCHER%" --jdeploy:command=myapp-cli -- %* +) +``` + +### Command with Launcher Implementation + +macOS script with `"implements": ["launcher"]`: +```sh +#!/usr/bin/env sh +# Launch the desktop app using the open command +exec open -a "/Users/alice/Applications/MyApp.app" "$@" +``` + +Linux script with `"implements": ["launcher"]`: +```sh +#!/usr/bin/env sh +# Launch the desktop app directly +exec "/home/alice/.jdeploy/apps/myapp/my-app" "$@" +``` + +Windows `.cmd` wrapper with `"implements": ["launcher"]`: +```cmd +@echo off +set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp.exe" +"%LAUNCHER%" %* +``` + +### Command with Service Controller Implementation + +Linux/macOS script with `"implements": ["service_controller"]`: +```sh +#!/usr/bin/env sh +# Check if first argument is "service" +if [ "$1" = "service" ]; then + shift + exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myappctl --jdeploy:service "$@" +else + exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myappctl -- "$@" +fi +``` + +Windows `.cmd` wrapper with `"implements": ["service_controller"]`: +```cmd +@echo off +set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp-cli.exe" +if "%~1"=="service" ( + shift + "%LAUNCHER%" --jdeploy:command=myappctl --jdeploy:service %* +) else ( + "%LAUNCHER%" --jdeploy:command=myappctl -- %* +) +``` + +### Command with Multiple Implementations + +Linux/macOS script with `"implements": ["service_controller", "updater"]`: +```sh +#!/usr/bin/env sh +LAUNCHER="/home/alice/.jdeploy/apps/myapp/my-app" + +# Check for updater: single "update" argument +if [ "$#" -eq 1 ] && [ "$1" = "update" ]; then + exec "$LAUNCHER" --jdeploy:update +fi + +# Check for service_controller: first argument is "service" +if [ "$1" = "service" ]; then + shift + exec "$LAUNCHER" --jdeploy:command=myappctl --jdeploy:service "$@" +fi + +# Default: normal command +exec "$LAUNCHER" --jdeploy:command=myappctl -- "$@" +``` + ## Auto-Update Behavior CLI command scripts reference the launcher by absolute path. The auto-update mechanism works as follows: diff --git a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java index 0748e5b5..3d992d90 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java +++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java @@ -12,8 +12,9 @@ public class CommandSpec { private final String name; private final String description; private final List args; + private final List implementations; - public CommandSpec(String name, String description, List args) { + public CommandSpec(String name, String description, List args, List implementations) { if (name == null) { throw new IllegalArgumentException("Command name cannot be null"); } @@ -24,6 +25,18 @@ public CommandSpec(String name, String description, List args) { } else { this.args = Collections.unmodifiableList(new ArrayList<>(args)); } + if (implementations == null) { + this.implementations = Collections.emptyList(); + } else { + this.implementations = Collections.unmodifiableList(new ArrayList<>(implementations)); + } + } + + /** + * Constructor for backward compatibility (no implementations specified). + */ + public CommandSpec(String name, String description, List args) { + this(name, description, args, null); } public String getName() { @@ -38,18 +51,34 @@ public List getArgs() { return args; } + public List getImplementations() { + return implementations; + } + + /** + * Checks if this command implements a specific behavior. + * @param implementation one of: "updater", "service_controller", "launcher" + * @return true if this command implements the given behavior + */ + public boolean implements_(String implementation) { + return implementations.contains(implementation); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; CommandSpec that = (CommandSpec) o; - return Objects.equals(name, that.name) && Objects.equals(description, that.description) && Objects.equals(args, that.args); + return Objects.equals(name, that.name) && + Objects.equals(description, that.description) && + Objects.equals(args, that.args) && + Objects.equals(implementations, that.implementations); } @Override public int hashCode() { - return Objects.hash(name, description, args); + return Objects.hash(name, description, args, implementations); } @Override @@ -58,6 +87,7 @@ public String toString() { "name='" + name + '\'' + ", description='" + description + '\'' + ", args=" + args + + ", implementations=" + implementations + '}'; } } diff --git a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java index ba386e1a..4b27258d 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java +++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java @@ -27,6 +27,23 @@ public class CommandSpecParser { */ private static final String DANGEROUS_ARG_CHARS_REGEX = "[;|&`]|\\$\\("; + /** + * Valid implementation types for the "implements" property. + */ + public static final String IMPL_UPDATER = "updater"; + public static final String IMPL_SERVICE_CONTROLLER = "service_controller"; + public static final String IMPL_LAUNCHER = "launcher"; + + private static final List VALID_IMPLEMENTATIONS; + + static { + List impls = new ArrayList<>(); + impls.add(IMPL_UPDATER); + impls.add(IMPL_SERVICE_CONTROLLER); + impls.add(IMPL_LAUNCHER); + VALID_IMPLEMENTATIONS = Collections.unmodifiableList(impls); + } + /** * Parses and returns the list of CommandSpec objects declared in jdeploy config under commands. * The returned list is sorted by command name to ensure deterministic ordering. @@ -95,7 +112,26 @@ public static List parseCommands(JSONObject jdeployConfig) { args.add(argValue); } } - result.add(new CommandSpec(name, description, args)); + + List implementations = new ArrayList<>(); + if (specObj.has("implements")) { + Object implObj = specObj.get("implements"); + if (!(implObj instanceof JSONArray)) { + throw new IllegalArgumentException("Command '" + name + "': 'implements' must be an array of strings"); + } + JSONArray implArr = (JSONArray) implObj; + for (int i = 0; i < implArr.length(); i++) { + Object el = implArr.get(i); + if (!(el instanceof String)) { + throw new IllegalArgumentException("Command '" + name + "': all 'implements' elements must be strings"); + } + String implValue = (String) el; + validateImplementation(name, implValue, i); + implementations.add(implValue); + } + } + + result.add(new CommandSpec(name, description, args, implementations)); } // sort by name for deterministic order @@ -123,6 +159,26 @@ private static void validateArg(String commandName, String arg, int index) { } } + /** + * Validates that an implementation string is one of the allowed values. + * + * @param commandName the command name (for error messages) + * @param implementation the implementation string to validate + * @param index the index of the implementation in the array (for error messages) + * @throws IllegalArgumentException if the implementation is not a valid value or is null + */ + private static void validateImplementation(String commandName, String implementation, int index) { + if (implementation == null) { + throw new IllegalArgumentException("Command '" + commandName + "': implementation at index " + index + " must not be null"); + } + if (!VALID_IMPLEMENTATIONS.contains(implementation)) { + throw new IllegalArgumentException( + "Command '" + commandName + "': implementation at index " + index + " is invalid: '" + implementation + "'. " + + "Must be one of: " + String.join(", ", VALID_IMPLEMENTATIONS) + ); + } + } + private CommandSpecParser() { // Utility class, no instantiation } From e309f01e76c987ab3ae5cbcc7b8798c776b3ae47 Mon Sep 17 00:00:00 2001 From: Steve Hannah Date: Fri, 2 Jan 2026 12:29:55 -0800 Subject: [PATCH 2/3] feat: Add support for commands implements property editing --- .../jdeploy/gui/tabs/CliCommandsPanel.java | 149 +++++++++++++++++- .../gui/tabs/CliCommandsPanelTest.java | 74 +++++++++ 2 files changed, 215 insertions(+), 8 deletions(-) diff --git a/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java index 8074e7b8..1c22a216 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java +++ b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java @@ -30,6 +30,9 @@ public class CliCommandsPanel extends JPanel { private JButton removeButton; private JTextField nameField; private JTextField descriptionField; + private JCheckBox updaterCheckbox; + private JCheckBox launcherCheckbox; + private JCheckBox serviceControllerCheckbox; private JTextArea argsField; private JLabel validationLabel; private ActionListener changeListener; @@ -179,6 +182,56 @@ private JPanel createRightPanel() { formPanel.add(Box.createVerticalStrut(10)); + // Implements field (checkboxes) + JPanel implLabelPanel = new JPanel(); + implLabelPanel.setOpaque(false); + implLabelPanel.setLayout(new BoxLayout(implLabelPanel, BoxLayout.X_AXIS)); + JLabel implLabel = new JLabel("Implements"); + implLabel.setFont(implLabel.getFont().deriveFont(Font.BOLD)); + implLabelPanel.add(implLabel); + implLabelPanel.add(Box.createHorizontalStrut(5)); + implLabelPanel.add(createInfoIcon("Special behaviors for this command.
See individual checkbox tooltips for details.")); + implLabelPanel.add(Box.createHorizontalGlue()); + implLabelPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + implLabelPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, implLabel.getPreferredSize().height)); + formPanel.add(implLabelPanel); + + // Checkboxes panel + JPanel checkboxPanel = new JPanel(); + checkboxPanel.setOpaque(false); + checkboxPanel.setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0)); + checkboxPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + updaterCheckbox = new JCheckBox("Updater"); + updaterCheckbox.setOpaque(false); + updaterCheckbox.setToolTipText("Intercepts 'update' argument to trigger app updates.
" + + "Example: myapp-cli update → calls launcher with --jdeploy:update"); + updaterCheckbox.addActionListener(e -> onFieldChanged()); + checkboxPanel.add(updaterCheckbox); + checkboxPanel.add(Box.createHorizontalStrut(15)); + + launcherCheckbox = new JCheckBox("Launcher"); + launcherCheckbox.setOpaque(false); + launcherCheckbox.setToolTipText("Launches the desktop GUI application.
" + + "Arguments are passed as file paths or URLs to open.
" + + "macOS: Uses 'open -a MyApp.app', others call binary directly."); + launcherCheckbox.addActionListener(e -> onFieldChanged()); + checkboxPanel.add(launcherCheckbox); + checkboxPanel.add(Box.createHorizontalStrut(15)); + + serviceControllerCheckbox = new JCheckBox("Service Controller"); + serviceControllerCheckbox.setOpaque(false); + serviceControllerCheckbox.setToolTipText("Intercepts 'service' as first argument for daemon control.
" + + "Example: myappctl service start → calls launcher with --jdeploy:service start"); + serviceControllerCheckbox.addActionListener(e -> onFieldChanged()); + checkboxPanel.add(serviceControllerCheckbox); + + // Constrain the height of the checkbox panel to prevent vertical expansion + checkboxPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, updaterCheckbox.getPreferredSize().height)); + formPanel.add(checkboxPanel); + + formPanel.add(Box.createVerticalStrut(10)); + // Arguments field JPanel argsLabelPanel = new JPanel(); argsLabelPanel.setOpaque(false); @@ -312,6 +365,9 @@ public void load(JSONObject jdeploy) { commandsModel.clear(); nameField.setText(""); descriptionField.setText(""); + updaterCheckbox.setSelected(false); + launcherCheckbox.setSelected(false); + serviceControllerCheckbox.setSelected(false); argsField.setForeground(Color.GRAY); argsField.setText(ARGS_PLACEHOLDER); validationLabel.setText(" "); @@ -434,6 +490,9 @@ private void onCommandSelected(ListSelectionEvent evt) { if (index < 0) { nameField.setText(""); descriptionField.setText(""); + updaterCheckbox.setSelected(false); + launcherCheckbox.setSelected(false); + serviceControllerCheckbox.setSelected(false); argsField.setForeground(Color.GRAY); argsField.setText(ARGS_PLACEHOLDER); validationLabel.setText(" "); @@ -453,12 +512,31 @@ private void loadCommandForEditing(String commandName) { isUpdatingUI = true; try { nameField.setText(commandName); - + // Load description and args from the backing data model JSONObject spec = commandsModel.get(commandName); if (spec != null) { descriptionField.setText(spec.optString("description", "")); - + + // Load implements array + updaterCheckbox.setSelected(false); + launcherCheckbox.setSelected(false); + serviceControllerCheckbox.setSelected(false); + + if (spec.has("implements")) { + JSONArray implArray = spec.getJSONArray("implements"); + for (int i = 0; i < implArray.length(); i++) { + String impl = implArray.getString(i); + if (CommandSpecParser.IMPL_UPDATER.equals(impl)) { + updaterCheckbox.setSelected(true); + } else if (CommandSpecParser.IMPL_LAUNCHER.equals(impl)) { + launcherCheckbox.setSelected(true); + } else if (CommandSpecParser.IMPL_SERVICE_CONTROLLER.equals(impl)) { + serviceControllerCheckbox.setSelected(true); + } + } + } + // Load args - join array elements with newlines if (spec.has("args")) { JSONArray argsArray = spec.getJSONArray("args"); @@ -475,10 +553,13 @@ private void loadCommandForEditing(String commandName) { } } else { descriptionField.setText(""); + updaterCheckbox.setSelected(false); + launcherCheckbox.setSelected(false); + serviceControllerCheckbox.setSelected(false); argsField.setForeground(Color.GRAY); argsField.setText(ARGS_PLACEHOLDER); } - + validationLabel.setText(" "); } finally { isUpdatingUI = false; @@ -537,7 +618,7 @@ private void onNameChanged() { if (spec == null) { spec = new JSONObject(); } - + // Save current form values into the spec before moving it String desc = descriptionField.getText().trim(); if (!desc.isEmpty()) { @@ -545,7 +626,24 @@ private void onNameChanged() { } else { spec.remove("description"); } - + + // Save implements array + JSONArray implArray = new JSONArray(); + if (updaterCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_UPDATER); + } + if (launcherCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_LAUNCHER); + } + if (serviceControllerCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER); + } + if (implArray.length() > 0) { + spec.put("implements", implArray); + } else { + spec.remove("implements"); + } + String argsText = argsField.getText().trim(); boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY); @@ -566,7 +664,7 @@ private void onNameChanged() { } else { spec.remove("args"); } - + commandsModel.put(name, spec); // Update the list (this will not trigger selection change) @@ -605,6 +703,9 @@ private void removeSelectedCommand() { removeButton.setEnabled(false); nameField.setText(""); descriptionField.setText(""); + updaterCheckbox.setSelected(false); + launcherCheckbox.setSelected(false); + serviceControllerCheckbox.setSelected(false); argsField.setForeground(Color.GRAY); argsField.setText(ARGS_PLACEHOLDER); validationLabel.setText(" "); @@ -631,6 +732,21 @@ private JSONObject buildCommandSpec(String name) { spec.put("description", desc); } + // Build implements array + JSONArray implArray = new JSONArray(); + if (updaterCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_UPDATER); + } + if (launcherCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_LAUNCHER); + } + if (serviceControllerCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER); + } + if (implArray.length() > 0) { + spec.put("implements", implArray); + } + String argsText = argsField.getText().trim(); // Don't save placeholder text as actual args boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY); @@ -670,7 +786,7 @@ private void onFieldChanged() { spec = new JSONObject(); commandsModel.put(currentName, spec); } - + // Update description String desc = descriptionField.getText().trim(); if (!desc.isEmpty()) { @@ -678,7 +794,24 @@ private void onFieldChanged() { } else { spec.remove("description"); } - + + // Update implements array + JSONArray implArray = new JSONArray(); + if (updaterCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_UPDATER); + } + if (launcherCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_LAUNCHER); + } + if (serviceControllerCheckbox.isSelected()) { + implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER); + } + if (implArray.length() > 0) { + spec.put("implements", implArray); + } else { + spec.remove("implements"); + } + // Update args String argsText = argsField.getText().trim(); boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY); diff --git a/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java index 8b167417..7d656d89 100644 --- a/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java +++ b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java @@ -183,4 +183,78 @@ public void testEditDescriptionAndSwitchCommand() { assertEquals(1, cmd2Args.length()); assertEquals("arg2", cmd2Args.getString(0)); } + + @Test + public void testImplementsPropertySaveAndLoad() { + // Create a command with implements array + JSONObject jdeploy = new JSONObject(); + JSONObject commands = new JSONObject(); + + JSONObject cmd1 = new JSONObject(); + cmd1.put("description", "Updater command"); + JSONArray impl1 = new JSONArray(); + impl1.put("updater"); + cmd1.put("implements", impl1); + + JSONObject cmd2 = new JSONObject(); + cmd2.put("description", "Launcher command"); + JSONArray impl2 = new JSONArray(); + impl2.put("launcher"); + cmd2.put("implements", impl2); + + JSONObject cmd3 = new JSONObject(); + cmd3.put("description", "Service controller with updater"); + JSONArray impl3 = new JSONArray(); + impl3.put("service_controller"); + impl3.put("updater"); + cmd3.put("implements", impl3); + + commands.put("updater-cmd", cmd1); + commands.put("launcher-cmd", cmd2); + commands.put("service-cmd", cmd3); + jdeploy.put("commands", commands); + + // Load and verify + panel.load(jdeploy); + + // Save and verify implements are preserved + JSONObject saved = new JSONObject(); + panel.save(saved); + + JSONObject savedCommands = saved.getJSONObject("commands"); + assertEquals(3, savedCommands.length()); + + // Verify updater-cmd has updater implementation + assertTrue(savedCommands.has("updater-cmd")); + JSONObject savedCmd1 = savedCommands.getJSONObject("updater-cmd"); + assertTrue(savedCmd1.has("implements")); + JSONArray savedImpl1 = savedCmd1.getJSONArray("implements"); + assertEquals(1, savedImpl1.length()); + assertEquals("updater", savedImpl1.getString(0)); + + // Verify launcher-cmd has launcher implementation + assertTrue(savedCommands.has("launcher-cmd")); + JSONObject savedCmd2 = savedCommands.getJSONObject("launcher-cmd"); + assertTrue(savedCmd2.has("implements")); + JSONArray savedImpl2 = savedCmd2.getJSONArray("implements"); + assertEquals(1, savedImpl2.length()); + assertEquals("launcher", savedImpl2.getString(0)); + + // Verify service-cmd has both implementations + assertTrue(savedCommands.has("service-cmd")); + JSONObject savedCmd3 = savedCommands.getJSONObject("service-cmd"); + assertTrue(savedCmd3.has("implements")); + JSONArray savedImpl3 = savedCmd3.getJSONArray("implements"); + assertEquals(2, savedImpl3.length()); + // Note: Order might vary, so check both are present + boolean hasServiceController = false; + boolean hasUpdater = false; + for (int i = 0; i < savedImpl3.length(); i++) { + String impl = savedImpl3.getString(i); + if ("service_controller".equals(impl)) hasServiceController = true; + if ("updater".equals(impl)) hasUpdater = true; + } + assertTrue(hasServiceController, "Should have service_controller implementation"); + assertTrue(hasUpdater, "Should have updater implementation"); + } } From 7dfd1efdb1a05c7dfd7a23a57a71f2933af83a09 Mon Sep 17 00:00:00 2001 From: Steve Hannah Date: Fri, 2 Jan 2026 18:52:35 -0800 Subject: [PATCH 3/3] feat: Add verbose install and uninstall logs --- .../ca/weblite/jdeploy/installer/Main.java | 447 +++++++++++------- .../weblite/jdeploy/installer/MainDebug.java | 20 +- .../cli/AbstractUnixCliCommandInstaller.java | 89 +++- .../cli/LinuxCliCommandInstaller.java | 26 + .../installer/cli/MacCliCommandInstaller.java | 15 + .../cli/WindowsCliCommandInstaller.java | 131 ++++- .../installer/logging/InstallationLogger.java | 377 +++++++++++++++ .../jdeploy/installer/win/InstallWindows.java | 211 ++++++++- .../installer/win/InstallWindowsRegistry.java | 119 ++++- .../installer/win/UninstallWindows.java | 77 ++- 10 files changed, 1288 insertions(+), 224 deletions(-) create mode 100644 installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java index f4ba5389..47815357 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -9,6 +9,7 @@ import ca.weblite.jdeploy.installer.events.InstallationFormEventDispatcher; import ca.weblite.jdeploy.installer.linux.LinuxAdminLauncherGenerator; import ca.weblite.jdeploy.installer.linux.MimeTypeHelper; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.mac.MacAdminLauncherGenerator; import ca.weblite.jdeploy.installer.services.InstallationDetectionService; import ca.weblite.jdeploy.installer.uninstall.FileUninstallManifestRepository; @@ -950,206 +951,274 @@ private void install() throws Exception { new UIAwareCollisionHandler(uiFactory, installationForm) ); } else if (Platform.getSystemPlatform().isMac()) { - File jdeployAppsDir = new File(System.getProperty("user.home") + File.separator + "Applications"); - if (!jdeployAppsDir.exists()) { - jdeployAppsDir.mkdirs(); - } - String nameSuffix = ""; - if (appInfo().getNpmVersion().startsWith("0.0.0-")) { - nameSuffix = " " + appInfo().getNpmVersion().substring(appInfo().getNpmVersion().indexOf("-") + 1).trim(); + // Create installation logger for Mac + InstallationLogger macLogger = null; + try { + macLogger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL); + } catch (IOException e) { + System.err.println("Warning: Failed to create installation logger: " + e.getMessage()); } + final InstallationLogger macInstallLogger = macLogger; - String appName = appInfo().getTitle() + nameSuffix; - File installAppPath = new File(jdeployAppsDir, appName+".app"); - if (installAppPath.exists() && installationSettings.isOverwriteApp()) { - FileUtils.deleteDirectory(installAppPath); - } - File tmpAppPath = null; - for (File candidateApp : new File(tmpBundles, target).listFiles()) { - if (candidateApp.getName().endsWith(".app")) { - int result = Runtime.getRuntime().exec(new String[]{"mv", candidateApp.getAbsolutePath(), installAppPath.getAbsolutePath()}).waitFor(); - if (result != 0) { - String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; - String technicalMessage = "Failed to move application bundle to " + installAppPath.getAbsolutePath() + ". mv command returned exit code " + result; - String userMessage = "" + - "

Installation Failed

" + - "

Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "

" + - "

Possible causes:

" + - "
    " + - "
  • You don't have write permission to the Applications directory
  • " + - "
  • The application is currently running (please close it and try again)
  • " + - "
" + - "

For technical details, check the log file:
" + - logPath + "

" + - ""; - throw new UserLangRuntimeException(technicalMessage, userMessage); + try { + File jdeployAppsDir = new File(System.getProperty("user.home") + File.separator + "Applications"); + if (!jdeployAppsDir.exists()) { + jdeployAppsDir.mkdirs(); + if (macInstallLogger != null) { + macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + jdeployAppsDir.getAbsolutePath(), "User Applications directory"); } - break; } - } - if (!installAppPath.exists()) { - String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; - String technicalMessage = "Application bundle does not exist at " + installAppPath.getAbsolutePath() + " after installation attempt"; - String userMessage = "" + - "

Installation Failed

" + - "

Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "

" + - "

Possible causes:

" + - "
    " + - "
  • You don't have write permission to the Applications directory
  • " + - "
  • The application is currently running (please close it and try again)
  • " + - "
" + - "

For technical details, check the log file:
" + - logPath + "

" + - ""; - throw new UserLangRuntimeException(technicalMessage, userMessage); - } - installedApp = installAppPath; - File adminWrapper = null; - File desktopAlias = null; - - if (appInfo().isRequireRunAsAdmin() || appInfo().isAllowRunAsAdmin()) { - MacAdminLauncherGenerator macAdminLauncherGenerator = new MacAdminLauncherGenerator(); - adminWrapper = macAdminLauncherGenerator.getAdminLauncherFile(installedApp); - if (adminWrapper.exists()) { - // delete the old recursively - FileUtils.deleteDirectory(adminWrapper); + String nameSuffix = ""; + if (appInfo().getNpmVersion().startsWith("0.0.0-")) { + nameSuffix = " " + appInfo().getNpmVersion().substring(appInfo().getNpmVersion().indexOf("-") + 1).trim(); } - adminWrapper = new MacAdminLauncherGenerator().generateAdminLauncher(installedApp); - } - if (installationSettings.isAddToDesktop()) { - desktopAlias = new File(System.getProperty("user.home") + File.separator + "Desktop" + File.separator + appName + ".app"); - if (desktopAlias.exists()) { - desktopAlias.delete(); + String appName = appInfo().getTitle() + nameSuffix; + File installAppPath = new File(jdeployAppsDir, appName+".app"); + if (installAppPath.exists() && installationSettings.isOverwriteApp()) { + FileUtils.deleteDirectory(installAppPath); + if (macInstallLogger != null) { + macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + installAppPath.getAbsolutePath(), "Existing app bundle (overwrite)"); + } } - String targetPath = installAppPath.getAbsolutePath(); - if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) { - targetPath = adminWrapper.getAbsolutePath(); + File tmpAppPath = null; + for (File candidateApp : new File(tmpBundles, target).listFiles()) { + if (candidateApp.getName().endsWith(".app")) { + int result = Runtime.getRuntime().exec(new String[]{"mv", candidateApp.getAbsolutePath(), installAppPath.getAbsolutePath()}).waitFor(); + if (result != 0) { + String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; + String technicalMessage = "Failed to move application bundle to " + installAppPath.getAbsolutePath() + ". mv command returned exit code " + result; + String userMessage = "" + + "

Installation Failed

" + + "

Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "

" + + "

Possible causes:

" + + "
    " + + "
  • You don't have write permission to the Applications directory
  • " + + "
  • The application is currently running (please close it and try again)
  • " + + "
" + + "

For technical details, check the log file:
" + + logPath + "

" + + ""; + throw new UserLangRuntimeException(technicalMessage, userMessage); + } + if (macInstallLogger != null) { + macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + installAppPath.getAbsolutePath(), "Installed application bundle"); + } + break; + } } - int result = Runtime.getRuntime().exec(new String[]{"ln", "-s", targetPath, desktopAlias.getAbsolutePath()}).waitFor(); - if (result != 0) { - throw new RuntimeException("Failed to make desktop alias."); + if (!installAppPath.exists()) { + String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; + String technicalMessage = "Application bundle does not exist at " + installAppPath.getAbsolutePath() + " after installation attempt"; + String userMessage = "" + + "

Installation Failed

" + + "

Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "

" + + "

Possible causes:

" + + "
    " + + "
  • You don't have write permission to the Applications directory
  • " + + "
  • The application is currently running (please close it and try again)
  • " + + "
" + + "

For technical details, check the log file:
" + + logPath + "

" + + ""; + throw new UserLangRuntimeException(technicalMessage, userMessage); } - } - - if (installationSettings.isAddToDock()) { - /* - #!/bin/bash - myapp="//Applications//System Preferences.app" - defaults write com.apple.dock persistent-apps -array-add "tile-datafile-data_CFURLString$myapp_CFURLStringType0" - osascript -e 'tell application "Dock" to quit' - osascript -e 'tell application "Dock" to activate' - */ - String targetPath = installAppPath.getAbsolutePath(); - if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) { - targetPath = adminWrapper.getAbsolutePath(); + installedApp = installAppPath; + File adminWrapper = null; + File desktopAlias = null; + + if (appInfo().isRequireRunAsAdmin() || appInfo().isAllowRunAsAdmin()) { + MacAdminLauncherGenerator macAdminLauncherGenerator = new MacAdminLauncherGenerator(); + adminWrapper = macAdminLauncherGenerator.getAdminLauncherFile(installedApp); + if (adminWrapper.exists()) { + // delete the old recursively + FileUtils.deleteDirectory(adminWrapper); + } + adminWrapper = new MacAdminLauncherGenerator().generateAdminLauncher(installedApp); + if (macInstallLogger != null) { + macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + adminWrapper.getAbsolutePath(), "Admin launcher wrapper"); + } } - String myapp = targetPath.replace('/', '#').replace("#", "//"); - File shellScript = File.createTempFile("installondock", ".sh"); - shellScript.deleteOnExit(); - System.out.println("Adding to dock: "+myapp); - String[] commands = new String[]{ - "/usr/bin/defaults write com.apple.dock persistent-apps -array-add \"tile-datafile-data_CFURLString"+myapp+"_CFURLStringType0\"", - "/usr/bin/osascript -e 'tell application \"Dock\" to quit'", - "/usr/bin/osascript -e 'tell application \"Dock\" to activate'" + if (installationSettings.isAddToDesktop()) { + desktopAlias = new File(System.getProperty("user.home") + File.separator + "Desktop" + File.separator + appName + ".app"); + if (desktopAlias.exists()) { + desktopAlias.delete(); + } + String targetPath = installAppPath.getAbsolutePath(); + if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) { + targetPath = adminWrapper.getAbsolutePath(); + } + int result = Runtime.getRuntime().exec(new String[]{"ln", "-s", targetPath, desktopAlias.getAbsolutePath()}).waitFor(); + if (result != 0) { + throw new RuntimeException("Failed to make desktop alias."); + } + if (macInstallLogger != null) { + macInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + desktopAlias.getAbsolutePath(), targetPath); + } + } - }; - try (PrintWriter printWriter = new PrintWriter(new FileOutputStream(shellScript))) { - printWriter.println("#!/bin/bash"); - for (String cmd : commands) { - printWriter.println(cmd); + if (installationSettings.isAddToDock()) { + /* + #!/bin/bash + myapp="//Applications//System Preferences.app" + defaults write com.apple.dock persistent-apps -array-add "tile-datafile-data_CFURLString$myapp_CFURLStringType0" + osascript -e 'tell application "Dock" to quit' + osascript -e 'tell application "Dock" to activate' + */ + String targetPath = installAppPath.getAbsolutePath(); + if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) { + targetPath = adminWrapper.getAbsolutePath(); + } + String myapp = targetPath.replace('/', '#').replace("#", "//"); + File shellScript = File.createTempFile("installondock", ".sh"); + shellScript.deleteOnExit(); + + System.out.println("Adding to dock: "+myapp); + String[] commands = new String[]{ + "/usr/bin/defaults write com.apple.dock persistent-apps -array-add \"tile-datafile-data_CFURLString"+myapp+"_CFURLStringType0\"", + "/usr/bin/osascript -e 'tell application \"Dock\" to quit'", + "/usr/bin/osascript -e 'tell application \"Dock\" to activate'" + + }; + try (PrintWriter printWriter = new PrintWriter(new FileOutputStream(shellScript))) { + printWriter.println("#!/bin/bash"); + for (String cmd : commands) { + printWriter.println(cmd); + } + } + shellScript.setExecutable(true, false); + Runtime.getRuntime().exec(shellScript.getAbsolutePath()); + if (macInstallLogger != null) { + macInstallLogger.logInfo("Added application to Dock: " + targetPath); } } - shellScript.setExecutable(true, false); - Runtime.getRuntime().exec(shellScript.getAbsolutePath()); - } - // Collect installation artifacts for manifest - List cliScriptFiles = new ArrayList<>(); - File cliLauncherSymlink = null; - - // Install CLI scripts/launchers on macOS (in ~/.local/bin) if the user requested either feature. - if (installationSettings.isInstallCliCommands() || installationSettings.isInstallCliLauncher()) { - File cliLauncher = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + CliInstallerConstants.CLI_LAUNCHER_NAME); - if (!cliLauncher.exists()) { - File fallback = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + "Client4JLauncher"); - if (fallback.exists()) { - cliLauncher = fallback; + // Collect installation artifacts for manifest + List cliScriptFiles = new ArrayList<>(); + File cliLauncherSymlink = null; + + // Install CLI scripts/launchers on macOS (in ~/.local/bin) if the user requested either feature. + if (installationSettings.isInstallCliCommands() || installationSettings.isInstallCliLauncher()) { + File cliLauncher = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + CliInstallerConstants.CLI_LAUNCHER_NAME); + if (!cliLauncher.exists()) { + File fallback = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + "Client4JLauncher"); + if (fallback.exists()) { + cliLauncher = fallback; + } + } + + List cliCommands = npmPackageVersion() != null ? npmPackageVersion().getCommands() : Collections.emptyList(); + MacCliCommandInstaller macCliInstaller = new MacCliCommandInstaller(); + // Wire collision handler for GUI-aware prompting + macCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm)); + macCliInstaller.setInstallationLogger(macInstallLogger); + cliScriptFiles.addAll(macCliInstaller.installCommands(cliLauncher, cliCommands, installationSettings)); + + // Track the CLI launcher symlink if it was created + if (installationSettings.isInstallCliLauncher() && installationSettings.isCommandLineSymlinkCreated()) { + String commandName = deriveCommandName(); + File binDir = new File(System.getProperty("user.home"), ".jdeploy" + File.separator + "bin-" + ArchitectureUtil.getArchitectureSuffix()); + cliLauncherSymlink = new File(binDir, commandName); } } - List commands = npmPackageVersion() != null ? npmPackageVersion().getCommands() : Collections.emptyList(); - MacCliCommandInstaller macCliInstaller = new MacCliCommandInstaller(); - // Wire collision handler for GUI-aware prompting - macCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm)); - cliScriptFiles.addAll(macCliInstaller.installCommands(cliLauncher, commands, installationSettings)); - - // Track the CLI launcher symlink if it was created - if (installationSettings.isInstallCliLauncher() && installationSettings.isCommandLineSymlinkCreated()) { - String commandName = deriveCommandName(); - File binDir = new File(System.getProperty("user.home"), ".jdeploy" + File.separator + "bin-" + ArchitectureUtil.getArchitectureSuffix()); - cliLauncherSymlink = new File(binDir, commandName); + // Build and persist uninstall manifest + persistMacInstallationManifest(installAppPath, adminWrapper, desktopAlias, + cliScriptFiles, cliLauncherSymlink, appName); + } finally { + // Close the installation logger + if (macInstallLogger != null) { + macInstallLogger.close(); } } - - // Build and persist uninstall manifest - persistMacInstallationManifest(installAppPath, adminWrapper, desktopAlias, - cliScriptFiles, cliLauncherSymlink, appName); } else if (Platform.getSystemPlatform().isLinux()) { - File tmpExePath = null; - for (File exeCandidate : Objects.requireNonNull(new File(tmpBundles, target).listFiles())) { - tmpExePath = exeCandidate; - } - if (tmpExePath == null) { - throw new RuntimeException("Failed to find launcher file after creation. Something must have gone wrong in generation process"); - } - File userHome = new File(System.getProperty("user.home")); - File jdeployHome = new File(userHome, ".jdeploy"); - File appsDir = new File(jdeployHome, "apps"); - File appDir = new File(appsDir, fullyQualifiedPackageName); - appDir.mkdirs(); - - String nameSuffix = ""; - String titleSuffix = ""; - if (appInfo().getNpmVersion().startsWith("0.0.0-")) { - String v = appInfo().getNpmVersion(); - nameSuffix = "-" +v.substring(v.indexOf("-") + 1).trim(); - titleSuffix = "-" + v.substring(v.indexOf("-") + 1).trim(); + // Create installation logger for Linux + InstallationLogger linuxLogger = null; + try { + linuxLogger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL); + } catch (IOException e) { + System.err.println("Warning: Failed to create installation logger: " + e.getMessage()); } + final InstallationLogger linuxInstallLogger = linuxLogger; - String exeName = deriveLinuxBinaryNameFromTitle(appInfo().getTitle()) + nameSuffix; - File exePath = new File(appDir, exeName); try { - FileUtil.copy(tmpExePath, exePath); - } catch (IOException e) { - String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; - String technicalMessage = "Failed to copy application launcher to " + exePath.getAbsolutePath() + ": " + e.getMessage(); - String userMessage = "" + - "

Installation Failed

" + - "

Could not install the application to:
" + appDir.getAbsolutePath() + "

" + - "

Possible causes:

" + - "
    " + - "
  • You don't have write permission to the directory
  • " + - "
  • The application is currently running (please close it and try again)
  • " + - "
" + - "

For technical details, check the log file:
" + - logPath + "

" + - ""; - throw new UserLangRuntimeException(technicalMessage, userMessage, e); - } - - // Copy the icon.png if it is present - File bundleIcon = new File(findAppXmlFile().getParentFile(), "icon.png"); - File iconPath = new File(exePath.getParentFile(), "icon.png"); - if (bundleIcon.exists()) { - FileUtil.copy(bundleIcon, iconPath); - } - installLinuxMimetypes(); - - installLinuxLinks(exePath, appInfo().getTitle() + titleSuffix); - installedApp = exePath; + File tmpExePath = null; + for (File exeCandidate : Objects.requireNonNull(new File(tmpBundles, target).listFiles())) { + tmpExePath = exeCandidate; + } + if (tmpExePath == null) { + throw new RuntimeException("Failed to find launcher file after creation. Something must have gone wrong in generation process"); + } + File userHome = new File(System.getProperty("user.home")); + File jdeployHome = new File(userHome, ".jdeploy"); + File appsDir = new File(jdeployHome, "apps"); + File appDir = new File(appsDir, fullyQualifiedPackageName); + boolean appDirCreated = !appDir.exists(); + appDir.mkdirs(); + if (appDirCreated && linuxInstallLogger != null) { + linuxInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + appDir.getAbsolutePath(), "Application directory"); + } + + String nameSuffix = ""; + String titleSuffix = ""; + if (appInfo().getNpmVersion().startsWith("0.0.0-")) { + String v = appInfo().getNpmVersion(); + nameSuffix = "-" +v.substring(v.indexOf("-") + 1).trim(); + titleSuffix = "-" + v.substring(v.indexOf("-") + 1).trim(); + } + String exeName = deriveLinuxBinaryNameFromTitle(appInfo().getTitle()) + nameSuffix; + File exePath = new File(appDir, exeName); + try { + FileUtil.copy(tmpExePath, exePath); + if (linuxInstallLogger != null) { + linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED, + exePath.getAbsolutePath(), "Application launcher"); + } + } catch (IOException e) { + String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log"; + String technicalMessage = "Failed to copy application launcher to " + exePath.getAbsolutePath() + ": " + e.getMessage(); + String userMessage = "" + + "

Installation Failed

" + + "

Could not install the application to:
" + appDir.getAbsolutePath() + "

" + + "

Possible causes:

" + + "
    " + + "
  • You don't have write permission to the directory
  • " + + "
  • The application is currently running (please close it and try again)
  • " + + "
" + + "

For technical details, check the log file:
" + + logPath + "

" + + ""; + throw new UserLangRuntimeException(technicalMessage, userMessage, e); + } + + // Copy the icon.png if it is present + File bundleIcon = new File(findAppXmlFile().getParentFile(), "icon.png"); + File iconPath = new File(exePath.getParentFile(), "icon.png"); + if (bundleIcon.exists()) { + FileUtil.copy(bundleIcon, iconPath); + if (linuxInstallLogger != null) { + linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED, + iconPath.getAbsolutePath(), "Application icon"); + } + } + installLinuxMimetypes(); + + installLinuxLinks(exePath, appInfo().getTitle() + titleSuffix, linuxInstallLogger); + installedApp = exePath; + } finally { + // Close the installation logger + if (linuxInstallLogger != null) { + linuxInstallLogger.close(); + } + } } File tmpPlatformBundles = new File(tmpBundles, target); @@ -1671,6 +1740,10 @@ private File addLinuxDesktopFile(File desktopDir, String filePrefix, String titl } public void installLinuxLinks(File launcherFile, String title) throws Exception { + installLinuxLinks(launcherFile, title, null); + } + + public void installLinuxLinks(File launcherFile, String title, InstallationLogger linuxInstallLogger) throws Exception { if (!launcherFile.exists()) { throw new IllegalStateException("Launcher "+launcherFile+" does not exist so we cannot install a shortcut to it."); } @@ -1680,6 +1753,10 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception File pngIcon = new File(launcherFile.getParentFile(), "icon.png"); if (!pngIcon.exists()) { IOUtil.copyResourceToFile(Main.class, "icon.png", pngIcon); + if (linuxInstallLogger != null) { + linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED, + pngIcon.getAbsolutePath(), "Default application icon"); + } } boolean hasDesktop = isDesktopEnvironmentAvailable(); @@ -1697,15 +1774,28 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception File desktopFile = addLinuxDesktopFile(desktopDir, title, title, pngIcon, launcherFile); if (desktopFile != null) { desktopFiles.add(desktopFile); + if (linuxInstallLogger != null) { + linuxInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + desktopFile.getAbsolutePath(), launcherFile.getAbsolutePath()); + } } } if (installationSettings.isAddToPrograms()) { File homeDir = new File(System.getProperty("user.home")); File applicationsDir = new File(homeDir, ".local"+File.separator+"share"+File.separator+"applications"); + boolean appDirCreated = !applicationsDir.exists(); applicationsDir.mkdirs(); + if (appDirCreated && linuxInstallLogger != null) { + linuxInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + applicationsDir.getAbsolutePath(), "Applications directory"); + } File desktopFile = addLinuxDesktopFile(applicationsDir, title, title, pngIcon, launcherFile); if (desktopFile != null) { applicationsDesktopFiles.add(desktopFile); + if (linuxInstallLogger != null) { + linuxInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + desktopFile.getAbsolutePath(), launcherFile.getAbsolutePath()); + } } // We need to run update desktop database before file type associations and url schemes will be @@ -1722,6 +1812,9 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception } } else { System.out.println("No desktop environment detected. Skipping desktop shortcuts and mimetype registration."); + if (linuxInstallLogger != null) { + linuxInstallLogger.logInfo("No desktop environment detected - skipping desktop shortcuts"); + } } // Install command-line scripts and/or symlink in ~/.local/bin if user requested either feature @@ -1730,9 +1823,13 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception LinuxCliCommandInstaller linuxCliInstaller = new LinuxCliCommandInstaller(); // Wire collision handler for GUI-aware prompting linuxCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm)); + linuxCliInstaller.setInstallationLogger(linuxInstallLogger); cliScriptFiles.addAll(linuxCliInstaller.installCommands(launcherFile, commands, installationSettings)); } else { System.out.println("Skipping CLI command and launcher installation (user opted out)"); + if (linuxInstallLogger != null) { + linuxInstallLogger.logInfo("CLI command and launcher installation skipped (user opted out)"); + } } // Persist uninstall manifest with all collected artifacts diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java b/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java index 684efe8b..6ebbe1c4 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java @@ -16,13 +16,27 @@ public static void main(String[] args) throws Exception { String code = args[0]; String version = args.length > 1 ? args[1] : "latest"; - File tempDir = File.createTempFile("jdeploy-debug-", ""); + File tempDir = File.createTempFile("jdeploy-debug2-", ""); tempDir.delete(); tempDir.mkdirs(); - + + // If client4j.launcher.path is set and exists, copy it to temp directory + // so that findInstallFilesDir() will find the downloaded .jdeploy-files + // instead of any stale test fixtures near the original launcher path + String originalLauncherPath = System.getProperty("client4j.launcher.path"); + if (originalLauncherPath != null) { + File originalLauncher = new File(originalLauncherPath); + if (originalLauncher.exists()) { + File newLauncher = new File(tempDir, originalLauncher.getName()); + FileUtils.copyFile(originalLauncher, newLauncher); + System.setProperty("client4j.launcher.path", newLauncher.getAbsolutePath()); + System.out.println("Copied launcher to: " + newLauncher.getAbsolutePath()); + } + } + File originalDir = new File(System.getProperty("user.dir")); System.setProperty("user.dir", tempDir.getAbsolutePath()); - + try { File jdeployFilesDir = DefaultInstallationContext.downloadJDeployBundleForCode(code, version, null); File targetJDeployFilesDir = new File(tempDir, ".jdeploy-files"); diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java index 72f18786..4c85cd1c 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.cli; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.jdeploy.installer.util.CliCommandBinDirResolver; import ca.weblite.jdeploy.installer.util.DebugLogger; @@ -34,16 +35,26 @@ public abstract class AbstractUnixCliCommandInstaller implements CliCommandInstaller { private CollisionHandler collisionHandler = new DefaultCollisionHandler(); + protected InstallationLogger installationLogger; /** * Sets the collision handler for detecting and resolving command name conflicts. - * + * * @param collisionHandler the handler to use for collision resolution */ public void setCollisionHandler(CollisionHandler collisionHandler) { this.collisionHandler = collisionHandler != null ? collisionHandler : new DefaultCollisionHandler(); } + /** + * Sets the installation logger for recording detailed operation logs. + * + * @param logger the installation logger to use + */ + public void setInstallationLogger(InstallationLogger logger) { + this.installationLogger = logger; + } + /** * Determines the binary directory where CLI commands will be installed. * Uses CliCommandBinDirResolver to compute the per-app bin directory (~/.jdeploy/bin-{arch}/{fqpn}/). @@ -215,8 +226,16 @@ public void uninstallCommands(File appDir) { try { scriptPath.delete(); System.out.println("Removed command-line script: " + scriptPath.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.DELETED, + scriptPath.getAbsolutePath(), null); + } } catch (Exception e) { System.err.println("Warning: Failed to remove command script for " + cmdName + ": " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.FAILED, + scriptPath.getAbsolutePath(), e.getMessage()); + } } } } @@ -234,12 +253,24 @@ public void uninstallCommands(File appDir) { if (remainingFiles != null) { for (File f : remainingFiles) { f.delete(); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + f.getAbsolutePath(), "Remaining file in bin directory"); + } } } binDir.delete(); System.out.println("Removed per-app bin directory: " + binDir.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + binDir.getAbsolutePath(), "Per-app CLI bin directory"); + } } catch (Exception e) { System.err.println("Warning: Failed to remove per-app bin directory: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED, + binDir.getAbsolutePath(), e.getMessage()); + } } } } @@ -253,8 +284,14 @@ public void uninstallCommands(File appDir) { try { manifestRepository.delete(packageName, source); System.out.println("Deleted manifest for package: " + packageName); + if (installationLogger != null) { + installationLogger.logInfo("Deleted CLI command manifest for package: " + packageName); + } } catch (Exception e) { System.err.println("Warning: Failed to delete manifest: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logError("Failed to delete manifest: " + e.getMessage()); + } } } @@ -263,8 +300,16 @@ public void uninstallCommands(File appDir) { if (metadataFile.exists()) { try { metadataFile.delete(); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + metadataFile.getAbsolutePath(), "CLI metadata file"); + } } catch (Exception e) { System.err.println("Warning: Failed to remove metadata file: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED, + metadataFile.getAbsolutePath(), e.getMessage()); + } } } } @@ -344,10 +389,18 @@ protected List installCommandScripts(File launcherPath, List if (action == CollisionAction.SKIP) { System.out.println("Skipping command '" + cmdName + "' - already owned by another app"); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.SKIPPED_COLLISION, + scriptPath.getAbsolutePath(), "Owned by another app: " + existingLauncherPath); + } continue; } // OVERWRITE - fall through to delete and recreate System.out.println("Overwriting command '" + cmdName + "' from another app"); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.OVERWRITTEN, + scriptPath.getAbsolutePath(), "Overwriting from another app: " + existingLauncherPath); + } } // Same app or couldn't parse - silently overwrite try { @@ -360,9 +413,17 @@ protected List installCommandScripts(File launcherPath, List try { writeCommandScript(scriptPath, launcherPath.getAbsolutePath(), command); System.out.println("Created command-line script: " + scriptPath.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.CREATED, + scriptPath.getAbsolutePath(), null); + } createdFiles.add(scriptPath); } catch (IOException ioe) { System.err.println("Warning: Failed to create command script for " + cmdName + ": " + ioe.getMessage()); + if (installationLogger != null) { + installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.FAILED, + scriptPath.getAbsolutePath(), ioe.getMessage()); + } } } @@ -435,12 +496,16 @@ protected boolean ensureBinDirExists(File binDir) { boolean created = binDir.mkdirs(); DebugLogger.log("mkdirs() returned: " + created); - + if (!created) { // Check if it was created by another process (race condition) if (binDir.exists() && binDir.isDirectory()) { DebugLogger.log("Directory now exists (created by another process)"); System.out.println("Created " + displayPath + " directory"); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + binDir.getAbsolutePath(), "CLI bin directory (race condition)"); + } return true; } @@ -452,22 +517,38 @@ protected boolean ensureBinDirExists(File binDir) { DebugLogger.log(" Parent now exists: " + parent.exists()); DebugLogger.log(" Parent now canWrite: " + parent.canWrite()); } - + System.err.println("Warning: Failed to create " + displayPath + " directory"); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED, + binDir.getAbsolutePath(), "Failed to create CLI bin directory"); + } return false; } DebugLogger.log("Successfully created directory: " + binDir.getAbsolutePath()); System.out.println("Created " + displayPath + " directory"); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + binDir.getAbsolutePath(), "CLI bin directory"); + } } else { DebugLogger.log("binDir already exists: " + binDir.getAbsolutePath()); DebugLogger.log(" isDirectory: " + binDir.isDirectory()); DebugLogger.log(" canWrite: " + binDir.canWrite()); - + if (!binDir.isDirectory()) { DebugLogger.log("ensureBinDirExists() failed: path exists but is not a directory"); System.err.println("Warning: " + displayPath + " exists but is not a directory"); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED, + binDir.getAbsolutePath(), "Path exists but is not a directory"); + } return false; } + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.SKIPPED_EXISTS, + binDir.getAbsolutePath(), "CLI bin directory already exists"); + } } return true; } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java index fa26a3b0..5e0935fd 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.cli; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.jdeploy.models.CommandSpec; @@ -62,6 +63,9 @@ public List installCommands(File launcherPath, List commands, if (anyCreated) { // Add per-app commands directory to PATH addToPath(commandsBinDir); + if (installationLogger != null) { + installationLogger.logPathChange(true, commandsBinDir.getAbsolutePath(), "Linux CLI commands"); + } } } } @@ -83,14 +87,25 @@ public List installCommands(File launcherPath, List commands, try { Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath()); System.out.println("Created command-line symlink: " + symlinkPath.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath()); + } settings.setCommandLineSymlinkCreated(true); createdFiles.add(symlinkPath); anyCreated = true; // Add ~/.local/bin to PATH addToPath(launcherBinDir); + if (installationLogger != null) { + installationLogger.logPathChange(true, launcherBinDir.getAbsolutePath(), "Linux CLI launcher"); + } } catch (Exception e) { System.err.println("Warning: Failed to create command-line symlink: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED, + symlinkPath.getAbsolutePath(), e.getMessage()); + } } } } @@ -146,10 +161,17 @@ public File installLauncher(File launcherPath, String commandName, InstallationS // Create symlink to the launcher Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath()); System.out.println("Created launcher symlink: " + symlinkPath.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath()); + } settings.setCommandLineSymlinkCreated(true); // Update PATH and save metadata boolean pathUpdated = addToPath(localBinDir); + if (pathUpdated && installationLogger != null) { + installationLogger.logPathChange(true, localBinDir.getAbsolutePath(), "Linux CLI launcher (installLauncher)"); + } File appDir = launcherPath.getParentFile(); // Save metadata to launcher's parent directory if it differs from bin, otherwise use bin File metadataDir = (appDir != null && !appDir.equals(localBinDir)) ? appDir : localBinDir; @@ -158,6 +180,10 @@ public File installLauncher(File launcherPath, String commandName, InstallationS return symlinkPath; } catch (IOException ioe) { System.err.println("Warning: Failed to create launcher symlink: " + ioe.getMessage()); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED, + symlinkPath.getAbsolutePath(), ioe.getMessage()); + } return null; } } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java index 3ffe6aea..bc561922 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.cli; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.jdeploy.installer.util.DebugLogger; import ca.weblite.jdeploy.models.CommandSpec; @@ -77,6 +78,9 @@ public List installCommands(File launcherPath, List commands, // Add per-app commands directory to PATH DebugLogger.log("Calling addToPath() for: " + commandsBinDir); addToPath(commandsBinDir); + if (installationLogger != null) { + installationLogger.logPathChange(true, commandsBinDir.getAbsolutePath(), "macOS CLI commands"); + } } } } else { @@ -107,6 +111,10 @@ public List installCommands(File launcherPath, List commands, Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath()); System.out.println("Created command-line symlink: " + symlinkPath.getAbsolutePath()); DebugLogger.log("Symlink created successfully: " + symlinkPath); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED, + symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath()); + } settings.setCommandLineSymlinkCreated(true); createdFiles.add(symlinkPath); anyCreated = true; @@ -115,10 +123,17 @@ public List installCommands(File launcherPath, List commands, if (commandsBinDir == null) { DebugLogger.log("Calling addToPath() for: " + launcherBinDir); addToPath(launcherBinDir); + if (installationLogger != null) { + installationLogger.logPathChange(true, launcherBinDir.getAbsolutePath(), "macOS CLI launcher"); + } } } catch (Exception e) { System.err.println("Warning: Failed to create command-line symlink: " + e.getMessage()); DebugLogger.log("Failed to create symlink: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED, + symlinkPath.getAbsolutePath(), e.getMessage()); + } } } } else { diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java index bf38fa3b..f58c4417 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.cli; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.jdeploy.installer.util.CliCommandBinDirResolver; import ca.weblite.jdeploy.installer.util.ArchitectureUtil; @@ -31,6 +32,7 @@ public class WindowsCliCommandInstaller implements CliCommandInstaller { private CollisionHandler collisionHandler = new DefaultCollisionHandler(); private RegistryOperations registryOperations; + private InstallationLogger installationLogger; /** * Sets the collision handler for detecting and resolving command name conflicts. @@ -44,13 +46,22 @@ public void setCollisionHandler(CollisionHandler collisionHandler) { /** * Sets the registry operations implementation for testing or custom scenarios. * If not set, JnaRegistryOperations will be used by default. - * + * * @param registryOperations the RegistryOperations implementation to use */ public void setRegistryOperations(RegistryOperations registryOperations) { this.registryOperations = registryOperations; } + /** + * Sets the installation logger for detailed operation logging. + * + * @param logger the InstallationLogger to use (may be null) + */ + public void setInstallationLogger(InstallationLogger logger) { + this.installationLogger = logger; + } + @Override public List installCommands(File launcherPath, List commands, InstallationSettings settings) { List createdFiles = new ArrayList<>(); @@ -135,8 +146,19 @@ public void uninstallCommands(File appDir) { String cliExeName = metadata.optString(CliInstallerConstants.CLI_EXE_KEY, null); if (cliExeName != null && !cliExeName.isEmpty()) { File cliExeFile = new File(appDir, cliExeName); - if (cliExeFile.exists() && !cliExeFile.delete()) { - System.err.println("Warning: Failed to delete CLI exe: " + cliExeFile.getAbsolutePath()); + if (cliExeFile.exists()) { + if (cliExeFile.delete()) { + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + cliExeFile.getAbsolutePath(), "CLI executable"); + } + } else { + System.err.println("Warning: Failed to delete CLI exe: " + cliExeFile.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED, + cliExeFile.getAbsolutePath(), "Failed to delete CLI executable"); + } + } } } @@ -167,8 +189,19 @@ public void uninstallCommands(File appDir) { for (int i = 0; i < wrappersArray.length(); i++) { String wrapperName = wrappersArray.getString(i); File wrapperFile = new File(userBinDir, wrapperName); - if (wrapperFile.exists() && !wrapperFile.delete()) { - System.err.println("Warning: Failed to delete wrapper file: " + wrapperFile.getAbsolutePath()); + if (wrapperFile.exists()) { + if (wrapperFile.delete()) { + if (installationLogger != null) { + installationLogger.logCliCommand(wrapperName, InstallationLogger.FileOperation.DELETED, + wrapperFile.getAbsolutePath(), null); + } + } else { + System.err.println("Warning: Failed to delete wrapper file: " + wrapperFile.getAbsolutePath()); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED, + wrapperFile.getAbsolutePath(), "Failed to delete wrapper"); + } + } } // Also attempt to delete the extensionless version (for Git Bash) @@ -177,8 +210,15 @@ public void uninstallCommands(File appDir) { : wrapperName; File shWrapperFile = new File(userBinDir, shWrapperName); - if (shWrapperFile.exists() && !shWrapperFile.delete()) { - System.err.println("Warning: Failed to delete shell wrapper file: " + shWrapperFile.getAbsolutePath()); + if (shWrapperFile.exists()) { + if (shWrapperFile.delete()) { + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + shWrapperFile.getAbsolutePath(), "Git Bash wrapper"); + } + } else { + System.err.println("Warning: Failed to delete shell wrapper file: " + shWrapperFile.getAbsolutePath()); + } } } } @@ -196,8 +236,15 @@ public void uninstallCommands(File appDir) { } userBinDir.delete(); System.out.println("Removed per-app bin directory: " + binDirPath); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + binDirPath, "Per-app bin directory"); + } } catch (Exception e) { System.err.println("Warning: Failed to remove per-app bin directory: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logError("Failed to remove per-app bin directory: " + binDirPath, e); + } } } } @@ -208,11 +255,17 @@ public void uninstallCommands(File appDir) { // Remove from PATH if it was added if (metadata.optBoolean(CliInstallerConstants.PATH_UPDATED_KEY, false)) { removeFromPath(perAppPathDir); + if (installationLogger != null) { + installationLogger.logPathChange(false, perAppPathDir.getAbsolutePath(), "Windows user PATH"); + } } // Remove from Git Bash path if it was added if (metadata.optBoolean(CliInstallerConstants.GIT_BASH_PATH_UPDATED_KEY, false)) { removeFromGitBashPath(perAppPathDir); + if (installationLogger != null) { + installationLogger.logInfo("Removed from Git Bash PATH: " + perAppPathDir.getAbsolutePath()); + } } // Delete manifest file via repository @@ -220,13 +273,21 @@ public void uninstallCommands(File appDir) { try { manifestRepository.delete(packageName, source); System.out.println("Deleted manifest for package: " + packageName); + if (installationLogger != null) { + installationLogger.logInfo("Deleted CLI manifest for package: " + packageName); + } } catch (Exception e) { System.err.println("Warning: Failed to delete manifest: " + e.getMessage()); } } // Delete legacy metadata file - if (!metadataFile.delete()) { + if (metadataFile.delete()) { + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + metadataFile.getAbsolutePath(), "CLI metadata file"); + } + } else { System.err.println("Warning: Failed to delete metadata file: " + metadataFile.getAbsolutePath()); } @@ -244,9 +305,20 @@ public boolean addToPath(File binDir) { try { // Use InstallWindowsRegistry to manage PATH via registry InstallWindowsRegistry registry = createRegistryHelper(); - return registry.addToUserPath(binDir); + boolean result = registry.addToUserPath(binDir); + if (installationLogger != null) { + if (result) { + installationLogger.logPathChange(true, binDir.getAbsolutePath(), "Windows user PATH"); + } else { + installationLogger.logInfo("PATH already contains: " + binDir.getAbsolutePath()); + } + } + return result; } catch (Exception e) { System.err.println("Warning: Failed to update user PATH in registry: " + e.getMessage()); + if (installationLogger != null) { + installationLogger.logError("Failed to update user PATH in registry", e); + } return false; } } @@ -286,8 +358,16 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li if (!binDir.exists()) { if (!binDir.mkdirs()) { + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED, + binDir.getAbsolutePath(), "Failed to create"); + } throw new IOException("Failed to create bin directory: " + binDir.getAbsolutePath()); } + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, + binDir.getAbsolutePath(), "CLI commands bin directory"); + } } for (CommandSpec cs : commands) { @@ -295,7 +375,12 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li File cmdWrapper = new File(binDir, name + ".cmd"); File shWrapper = new File(binDir, name); + if (installationLogger != null) { + installationLogger.logInfo("Processing CLI command: " + name); + } + // Check for collision with existing wrapper (check .cmd as the primary indicator) + boolean wasOverwritten = false; if (cmdWrapper.exists()) { String existingLauncherPath = extractLauncherPathFromCmdFile(cmdWrapper); @@ -309,16 +394,33 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li if (action == CollisionAction.SKIP) { System.out.println("Skipping command '" + name + "' - already owned by another app"); + if (installationLogger != null) { + installationLogger.logCliCommand(name, InstallationLogger.FileOperation.SKIPPED_COLLISION, + cmdWrapper.getAbsolutePath(), + "Owned by: " + existingLauncherPath); + } continue; } // OVERWRITE - fall through to delete and recreate System.out.println("Overwriting command '" + name + "' from another app"); + if (installationLogger != null) { + installationLogger.logInfo("Overwriting command from different app: " + existingLauncherPath); + } } // Same app or couldn't parse - silently overwrite + wasOverwritten = true; cmdWrapper.delete(); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + cmdWrapper.getAbsolutePath(), "Existing wrapper (same app update)"); + } } if (shWrapper.exists()) { shWrapper.delete(); + if (installationLogger != null) { + installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED, + shWrapper.getAbsolutePath(), "Existing shell wrapper"); + } } // Generate wrapper content based on implementations @@ -328,9 +430,20 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li FileUtils.writeStringToFile(cmdWrapper, cmdContent, "UTF-8"); cmdWrapper.setExecutable(true, false); created.add(cmdWrapper); + if (installationLogger != null) { + installationLogger.logCliCommand(name, + wasOverwritten ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + cmdWrapper.getAbsolutePath(), + "Launcher: " + launcherPath.getAbsolutePath()); + } FileUtils.writeStringToFile(shWrapper, shContent, "UTF-8"); shWrapper.setExecutable(true, false); + if (installationLogger != null) { + installationLogger.logFileOperation( + wasOverwritten ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + shWrapper.getAbsolutePath(), "Git Bash wrapper for " + name); + } } return created; diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java b/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java new file mode 100644 index 00000000..a638c613 --- /dev/null +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java @@ -0,0 +1,377 @@ +package ca.weblite.jdeploy.installer.logging; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Comprehensive logger for installation and uninstallation operations. + * Records all file operations, registry changes, and other installation activities + * to a dedicated log file for debugging and auditing purposes. + * + * Log files are stored at: + * - Installs: ~/.jdeploy/log/jdeploy-installer/installs/{packageName}-{timestamp}.log + * - Uninstalls: ~/.jdeploy/log/jdeploy-installer/uninstalls/{packageName}-{timestamp}.log + */ +public class InstallationLogger implements Closeable { + + public enum OperationType { + INSTALL, + UNINSTALL + } + + public enum FileOperation { + CREATED, + DELETED, + OVERWRITTEN, + SKIPPED_EXISTS, + SKIPPED_COLLISION, + COPIED, + MOVED, + MODIFIED, + FAILED + } + + public enum RegistryOperation { + KEY_CREATED, + KEY_DELETED, + VALUE_SET, + VALUE_DELETED, + PATH_ADDED, + PATH_REMOVED, + FAILED + } + + public enum DirectoryOperation { + CREATED, + DELETED, + SKIPPED_EXISTS, + SKIPPED_NOT_EMPTY, + FAILED + } + + private static final SimpleDateFormat TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + private static final SimpleDateFormat FILE_TIMESTAMP_FORMAT = new SimpleDateFormat("yyyyMMdd-HHmmss"); + + private final PrintWriter writer; + private final File logFile; + private final String packageName; + private final OperationType operationType; + private final long startTime; + + private int filesCreated = 0; + private int filesDeleted = 0; + private int filesOverwritten = 0; + private int filesSkipped = 0; + private int filesFailed = 0; + private int registryOperations = 0; + private int directoriesCreated = 0; + private int directoriesDeleted = 0; + + /** + * Creates a new installation logger. + * + * @param packageName the fully qualified package name + * @param operationType whether this is an install or uninstall operation + * @throws IOException if the log file cannot be created + */ + public InstallationLogger(String packageName, OperationType operationType) throws IOException { + this.packageName = packageName; + this.operationType = operationType; + this.startTime = System.currentTimeMillis(); + + String userHome = System.getProperty("user.home"); + String subDir = operationType == OperationType.INSTALL ? "installs" : "uninstalls"; + File logDir = new File(userHome, ".jdeploy" + File.separator + "log" + + File.separator + "jdeploy-installer" + File.separator + subDir); + + if (!logDir.exists() && !logDir.mkdirs()) { + throw new IOException("Failed to create log directory: " + logDir.getAbsolutePath()); + } + + String timestamp = FILE_TIMESTAMP_FORMAT.format(new Date()); + String safePackageName = packageName.replaceAll("[^a-zA-Z0-9.-]", "_"); + this.logFile = new File(logDir, safePackageName + "-" + timestamp + ".log"); + + this.writer = new PrintWriter(new BufferedWriter(new FileWriter(logFile)), true); + + writeHeader(); + } + + private void writeHeader() { + writeLine("================================================================================"); + writeLine(operationType == OperationType.INSTALL ? "INSTALLATION LOG" : "UNINSTALLATION LOG"); + writeLine("================================================================================"); + writeLine("Package: " + packageName); + writeLine("Started: " + TIMESTAMP_FORMAT.format(new Date(startTime))); + writeLine("Log File: " + logFile.getAbsolutePath()); + writeLine("--------------------------------------------------------------------------------"); + writeLine(""); + } + + private synchronized void writeLine(String message) { + writer.println(message); + } + + private synchronized void writeTimestampedLine(String message) { + String timestamp = TIMESTAMP_FORMAT.format(new Date()); + writer.println("[" + timestamp + "] " + message); + } + + /** + * Logs the start of a major section. + */ + public void logSection(String sectionName) { + writeLine(""); + writeLine("--- " + sectionName + " ---"); + } + + /** + * Logs a file operation. + */ + public void logFileOperation(FileOperation operation, String filePath, String details) { + String prefix = getFileOperationPrefix(operation); + String message = prefix + " " + filePath; + if (details != null && !details.isEmpty()) { + message += " (" + details + ")"; + } + writeTimestampedLine(message); + + // Update counters + switch (operation) { + case CREATED: + case COPIED: + filesCreated++; + break; + case DELETED: + filesDeleted++; + break; + case OVERWRITTEN: + case MODIFIED: + filesOverwritten++; + break; + case SKIPPED_EXISTS: + case SKIPPED_COLLISION: + filesSkipped++; + break; + case FAILED: + filesFailed++; + break; + } + } + + /** + * Logs a file operation without additional details. + */ + public void logFileOperation(FileOperation operation, String filePath) { + logFileOperation(operation, filePath, null); + } + + private String getFileOperationPrefix(FileOperation operation) { + switch (operation) { + case CREATED: return "[FILE CREATED]"; + case DELETED: return "[FILE DELETED]"; + case OVERWRITTEN: return "[FILE OVERWRITTEN]"; + case SKIPPED_EXISTS: return "[FILE SKIPPED - EXISTS]"; + case SKIPPED_COLLISION: return "[FILE SKIPPED - COLLISION]"; + case COPIED: return "[FILE COPIED]"; + case MOVED: return "[FILE MOVED]"; + case MODIFIED: return "[FILE MODIFIED]"; + case FAILED: return "[FILE FAILED]"; + default: return "[FILE]"; + } + } + + /** + * Logs a directory operation. + */ + public void logDirectoryOperation(DirectoryOperation operation, String dirPath, String details) { + String prefix = getDirectoryOperationPrefix(operation); + String message = prefix + " " + dirPath; + if (details != null && !details.isEmpty()) { + message += " (" + details + ")"; + } + writeTimestampedLine(message); + + // Update counters + switch (operation) { + case CREATED: + directoriesCreated++; + break; + case DELETED: + directoriesDeleted++; + break; + } + } + + /** + * Logs a directory operation without additional details. + */ + public void logDirectoryOperation(DirectoryOperation operation, String dirPath) { + logDirectoryOperation(operation, dirPath, null); + } + + private String getDirectoryOperationPrefix(DirectoryOperation operation) { + switch (operation) { + case CREATED: return "[DIR CREATED]"; + case DELETED: return "[DIR DELETED]"; + case SKIPPED_EXISTS: return "[DIR SKIPPED - EXISTS]"; + case SKIPPED_NOT_EMPTY: return "[DIR SKIPPED - NOT EMPTY]"; + case FAILED: return "[DIR FAILED]"; + default: return "[DIR]"; + } + } + + /** + * Logs a registry operation. + */ + public void logRegistryOperation(RegistryOperation operation, String registryPath, String details) { + String prefix = getRegistryOperationPrefix(operation); + String message = prefix + " " + registryPath; + if (details != null && !details.isEmpty()) { + message += " (" + details + ")"; + } + writeTimestampedLine(message); + + if (operation != RegistryOperation.FAILED) { + registryOperations++; + } + } + + /** + * Logs a registry operation without additional details. + */ + public void logRegistryOperation(RegistryOperation operation, String registryPath) { + logRegistryOperation(operation, registryPath, null); + } + + private String getRegistryOperationPrefix(RegistryOperation operation) { + switch (operation) { + case KEY_CREATED: return "[REGISTRY KEY CREATED]"; + case KEY_DELETED: return "[REGISTRY KEY DELETED]"; + case VALUE_SET: return "[REGISTRY VALUE SET]"; + case VALUE_DELETED: return "[REGISTRY VALUE DELETED]"; + case PATH_ADDED: return "[REGISTRY PATH ADDED]"; + case PATH_REMOVED: return "[REGISTRY PATH REMOVED]"; + case FAILED: return "[REGISTRY FAILED]"; + default: return "[REGISTRY]"; + } + } + + /** + * Logs a CLI command wrapper operation. + */ + public void logCliCommand(String commandName, FileOperation operation, String wrapperPath, String details) { + String prefix = "[CLI COMMAND " + operation.name() + "]"; + String message = prefix + " " + commandName + " -> " + wrapperPath; + if (details != null && !details.isEmpty()) { + message += " (" + details + ")"; + } + writeTimestampedLine(message); + } + + /** + * Logs a shortcut operation. + */ + public void logShortcut(FileOperation operation, String shortcutPath, String targetPath) { + String prefix = "[SHORTCUT " + operation.name() + "]"; + String message = prefix + " " + shortcutPath; + if (targetPath != null) { + message += " -> " + targetPath; + } + writeTimestampedLine(message); + } + + /** + * Logs an informational message. + */ + public void logInfo(String message) { + writeTimestampedLine("[INFO] " + message); + } + + /** + * Logs a warning message. + */ + public void logWarning(String message) { + writeTimestampedLine("[WARNING] " + message); + } + + /** + * Logs an error message. + */ + public void logError(String message) { + writeTimestampedLine("[ERROR] " + message); + } + + /** + * Logs an error with exception details. + */ + public void logError(String message, Throwable ex) { + writeTimestampedLine("[ERROR] " + message + ": " + ex.getMessage()); + ex.printStackTrace(writer); + } + + /** + * Logs PATH environment variable changes. + */ + public void logPathChange(boolean added, String pathEntry, String context) { + String action = added ? "ADDED TO" : "REMOVED FROM"; + String message = "[PATH " + action + "] " + pathEntry; + if (context != null) { + message += " (" + context + ")"; + } + writeTimestampedLine(message); + } + + /** + * Gets the log file path. + */ + public File getLogFile() { + return logFile; + } + + /** + * Writes a summary and closes the logger. + */ + @Override + public void close() { + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + writeLine(""); + writeLine("--------------------------------------------------------------------------------"); + writeLine("SUMMARY"); + writeLine("--------------------------------------------------------------------------------"); + writeLine("Completed: " + TIMESTAMP_FORMAT.format(new Date(endTime))); + writeLine("Duration: " + formatDuration(duration)); + writeLine(""); + writeLine("Files created: " + filesCreated); + writeLine("Files deleted: " + filesDeleted); + writeLine("Files overwritten: " + filesOverwritten); + writeLine("Files skipped: " + filesSkipped); + writeLine("Files failed: " + filesFailed); + writeLine("Directories created: " + directoriesCreated); + writeLine("Directories deleted: " + directoriesDeleted); + writeLine("Registry operations: " + registryOperations); + writeLine(""); + writeLine("================================================================================"); + writeLine("END OF LOG"); + writeLine("================================================================================"); + + writer.close(); + + // Also print log file location to stdout for visibility + System.out.println("Installation log written to: " + logFile.getAbsolutePath()); + } + + private String formatDuration(long millis) { + long seconds = millis / 1000; + long ms = millis % 1000; + if (seconds < 60) { + return seconds + "." + String.format("%03d", ms) + " seconds"; + } + long minutes = seconds / 60; + seconds = seconds % 60; + return minutes + " minutes, " + seconds + " seconds"; + } +} diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java index d5d11278..d13bb80e 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java @@ -6,6 +6,7 @@ import ca.weblite.jdeploy.installer.Main; import ca.weblite.jdeploy.installer.cli.CollisionHandler; import ca.weblite.jdeploy.installer.cli.UIAwareCollisionHandler; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.jdeploy.installer.uninstall.UninstallManifestBuilder; import ca.weblite.jdeploy.installer.uninstall.UninstallManifestWriter; @@ -52,6 +53,32 @@ public File install( File tmpBundles, ca.weblite.jdeploy.installer.npm.NPMPackageVersion npmPackageVersion, CollisionHandler collisionHandler + ) throws Exception { + // Create installation logger + InstallationLogger logger = null; + try { + logger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL); + } catch (IOException e) { + System.err.println("Warning: Failed to create installation logger: " + e.getMessage()); + } + + try { + return doInstall(context, installationSettings, fullyQualifiedPackageName, tmpBundles, npmPackageVersion, collisionHandler, logger); + } finally { + if (logger != null) { + logger.close(); + } + } + } + + private File doInstall( + InstallationContext context, + InstallationSettings installationSettings, + String fullyQualifiedPackageName, + File tmpBundles, + ca.weblite.jdeploy.installer.npm.NPMPackageVersion npmPackageVersion, + CollisionHandler collisionHandler, + InstallationLogger logger ) throws Exception { AppInfo appInfo = installationSettings.getAppInfo(); File tmpExePath = findTmpExeFile(tmpBundles); @@ -60,7 +87,20 @@ public File install( File jdeployHome = new File(userHome, ".jdeploy"); File appsDir = new File(jdeployHome, "apps"); File appDir = new File(appsDir, fullyQualifiedPackageName); + + if (logger != null) { + logger.logInfo("Starting installation of " + appInfo.getTitle() + " version " + appInfo.getNpmVersion()); + logger.logInfo("Package: " + appInfo.getNpmPackage()); + logger.logInfo("Source: " + (appInfo.getNpmSource() != null ? appInfo.getNpmSource() : "NPM")); + logger.logSection("Creating Application Directory"); + } + + boolean appDirCreated = !appDir.exists(); appDir.mkdirs(); + if (logger != null && appDirCreated) { + logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, appDir.getAbsolutePath()); + } + String nameSuffix = ""; if (appInfo.getNpmVersion().startsWith("0.0.0-")) { @@ -79,17 +119,39 @@ public File install( File cliExePath = null; try { + if (logger != null) { + logger.logSection("Installing Application Executable"); + } + boolean exeExisted = exePath.exists(); FileUtil.copy(tmpExePath, exePath); exePath.setExecutable(true, false); - + if (logger != null) { + logger.logFileOperation( + exeExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + exePath.getAbsolutePath(), + "Main application executable" + ); + } + // Copy CLI launcher if CLI commands will be installed if (installationSettings.isInstallCliCommands()) { cliExePath = new File(exePath.getParentFile(), exePath.getName().replace(".exe", CliInstallerConstants.CLI_LAUNCHER_SUFFIX + ".exe")); + boolean cliExeExisted = cliExePath.exists(); WindowsPESubsystemModifier.copyAndModifySubsystem(exePath, cliExePath); cliExePath.setExecutable(true, false); + if (logger != null) { + logger.logFileOperation( + cliExeExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + cliExePath.getAbsolutePath(), + "CLI launcher executable" + ); + } } } catch (IOException e) { + if (logger != null) { + logger.logError("Failed to copy application executable", e); + } String logPath = System.getProperty("user.home") + "\\.jdeploy\\log\\jdeploy-installer.log"; String technicalMessage = "Failed to copy application executable to " + exePath.getAbsolutePath() + ": " + e.getMessage(); String userMessage = "" + @@ -108,25 +170,45 @@ public File install( } // Copy the icon.png if it is present + if (logger != null) { + logger.logSection("Installing Application Icons"); + } File bundleIcon = new File(appXmlFile.getParentFile(), "icon.png"); File iconPath = new File(appDir, "icon.png"); File icoPath = new File(appDir, "icon.ico"); if (Files.exists(bundleIcon.toPath())) { + boolean iconExisted = iconPath.exists(); FileUtil.copy(bundleIcon, iconPath); + if (logger != null) { + logger.logFileOperation( + iconExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + iconPath.getAbsolutePath(), + "Application icon (PNG)" + ); + } } + List shortcutFiles = installWindowsLinks( appInfo, installationSettings, exePath, appDir, - appInfo.getTitle() + nameSuffix + appInfo.getTitle() + nameSuffix, + logger ); + if (logger != null) { + logger.logSection("Registry Operations"); + } + File registryBackupLogs = new File(appDir, "registry-backup-logs"); if (!registryBackupLogs.exists()) { registryBackupLogs.mkdirs(); + if (logger != null) { + logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, registryBackupLogs.getAbsolutePath()); + } } int nextLogIndex = 0; for (File child : registryBackupLogs.listFiles()) { @@ -142,25 +224,41 @@ public File install( } File registryBackupLog = new File(registryBackupLogs, nextLogIndex+".reg"); try (FileOutputStream fos = new FileOutputStream(registryBackupLog)) { - InstallWindowsRegistry registryInstaller = new InstallWindowsRegistry(appInfo, exePath, icoPath, fos); + InstallWindowsRegistry registryInstaller = new InstallWindowsRegistry(appInfo, exePath, icoPath, fos, logger); registryInstaller.register(); // Install CLI commands if user requested List commands = npmPackageVersion != null ? npmPackageVersion.getCommands() : null; List cliWrapperFiles = null; if (installationSettings.isInstallCliCommands() && commands != null && !commands.isEmpty()) { - ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller cliInstaller = + if (logger != null) { + logger.logSection("Installing CLI Commands"); + logger.logInfo("Installing " + commands.size() + " CLI command(s)"); + } + ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller cliInstaller = new ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller(); cliInstaller.setCollisionHandler(collisionHandler); + cliInstaller.setInstallationLogger(logger); File launcherForCommands = cliExePath != null ? cliExePath : exePath; cliWrapperFiles = cliInstaller.installCommands(launcherForCommands, commands, installationSettings); + if (logger != null) { + logger.logInfo("CLI commands installation complete: " + (cliWrapperFiles != null ? cliWrapperFiles.size() : 0) + " wrapper(s) created"); + } } //Try to copy the uninstaller + if (logger != null) { + logger.logSection("Installing Uninstaller"); + } File uninstallerPath = registryInstaller.getUninstallerPath(); + boolean uninstallerDirCreated = !uninstallerPath.getParentFile().exists(); uninstallerPath.getParentFile().mkdirs(); + if (logger != null && uninstallerDirCreated) { + logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, uninstallerPath.getParentFile().getAbsolutePath()); + } File installerExePath = new File(System.getProperty("client4j.launcher.path")); - if (uninstallerPath.exists()) { + boolean uninstallerExisted = uninstallerPath.exists(); + if (uninstallerExisted) { if (!uninstallerPath.canWrite()) { uninstallerPath.setWritable(true, false); try { @@ -171,8 +269,14 @@ public File install( } } if (!uninstallerPath.delete()) { + if (logger != null) { + logger.logFileOperation(InstallationLogger.FileOperation.FAILED, uninstallerPath.getAbsolutePath(), "Failed to delete existing uninstaller"); + } throw new IOException("Failed to delete uninstaller: "+uninstallerPath); } + if (logger != null) { + logger.logFileOperation(InstallationLogger.FileOperation.DELETED, uninstallerPath.getAbsolutePath(), "Existing uninstaller"); + } try { // Give Windows time to update its cache @@ -183,13 +287,14 @@ public File install( } FileUtils.copyFile(installerExePath, uninstallerPath); uninstallerPath.setExecutable(true, false); - FileUtils.copyDirectory( - context.findInstallFilesDir(), - new File( - uninstallerPath.getParentFile(), - context.findInstallFilesDir().getName() - ) - ); + if (logger != null) { + logger.logFileOperation(InstallationLogger.FileOperation.CREATED, uninstallerPath.getAbsolutePath(), "Uninstaller executable"); + } + File jdeployFilesDestDir = new File(uninstallerPath.getParentFile(), context.findInstallFilesDir().getName()); + FileUtils.copyDirectory(context.findInstallFilesDir(), jdeployFilesDestDir); + if (logger != null) { + logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, jdeployFilesDestDir.getAbsolutePath(), "jDeploy files for uninstaller"); + } // Build and write uninstall manifest try { @@ -339,37 +444,65 @@ private List installWindowsLinks( InstallationSettings installationSettings, File exePath, File appDir, - String appTitle + String appTitle, + InstallationLogger logger ) throws Exception { + if (logger != null) { + logger.logSection("Creating Windows Shortcuts"); + } List shortcutFiles = new ArrayList<>(); File pngIconPath = new File(appDir, "icon.png"); File icoPath = new File(appDir.getCanonicalFile(), "icon.ico"); if (!Files.exists(pngIconPath.toPath())) { copyResourceToFile(Main.class, "icon.png", pngIconPath); + if (logger != null) { + logger.logFileOperation(InstallationLogger.FileOperation.CREATED, pngIconPath.getAbsolutePath(), "Default icon (PNG)"); + } } if (!Files.exists(pngIconPath.toPath())) { throw new IOException("Failed to create the .ico file for some reason. "+icoPath); } + boolean icoExisted = icoPath.exists(); convertWindowsIcon(pngIconPath.getCanonicalFile(), icoPath); + if (logger != null) { + logger.logFileOperation( + icoExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED, + icoPath.getAbsolutePath(), + "Windows icon (ICO)" + ); + } if (installationSettings.isAddToDesktop()) { try { if (appInfo.isRequireRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, true); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } else if (appInfo.isAllowRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } File shortcutAdmin = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true); - if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin); + if (shortcutAdmin != null) { + shortcutFiles.add(shortcutAdmin); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath()); + } } else { File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } } catch (Exception ex) { + if (logger != null) logger.logError("Failed to install desktop shortcut", ex); throw new RuntimeException("Failed to install desktop shortcut", ex); } } @@ -377,18 +510,31 @@ private List installWindowsLinks( try { if (appInfo.isRequireRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, true); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } else if (appInfo.isAllowRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } File shortcutAdmin = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true); - if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin); + if (shortcutAdmin != null) { + shortcutFiles.add(shortcutAdmin); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath()); + } } else { File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } } catch (Exception ex) { + if (logger != null) logger.logError("Failed to install program menu shortcut", ex); throw new RuntimeException("Failed to install program menu shortcut", ex); } @@ -397,21 +543,34 @@ private List installWindowsLinks( try { if (appInfo.isRequireRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, true); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } else if (appInfo.isAllowRunAsAdmin()) { File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } File shortcutAdmin = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true); - if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin); + if (shortcutAdmin != null) { + shortcutFiles.add(shortcutAdmin); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath()); + } } else { File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, false); - if (shortcut != null) shortcutFiles.add(shortcut); + if (shortcut != null) { + shortcutFiles.add(shortcut); + if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath()); + } } } catch (Exception ex) { + if (logger != null) logger.logError("Failed to install start menu shortcut", ex); throw new RuntimeException("Failed to install start menu shortcut", ex); } } - + return shortcutFiles; } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java index 0381176e..ee32b877 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.win; import ca.weblite.jdeploy.app.AppInfo; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.models.InstallationSettings; import ca.weblite.tools.io.MD5; import org.apache.commons.io.FileUtils; @@ -23,6 +24,7 @@ public class InstallWindowsRegistry { private File icon, exe; private RegistryOperations registryOps; private boolean skipWinRegistryOperations = false; + private InstallationLogger installationLogger; private static final Set contactExtensions = new HashSet<>(); private static final Set mediaExtensions = new HashSet<>(); @@ -191,7 +193,17 @@ public InstallWindowsRegistry( File icon, OutputStream backupLog ) { - this(appInfo, exe, icon, backupLog, new JnaRegistryOperations()); + this(appInfo, exe, icon, backupLog, new JnaRegistryOperations(), null); + } + + public InstallWindowsRegistry( + AppInfo appInfo, + File exe, + File icon, + OutputStream backupLog, + InstallationLogger logger + ) { + this(appInfo, exe, icon, backupLog, new JnaRegistryOperations(), logger); } public InstallWindowsRegistry( @@ -200,11 +212,23 @@ public InstallWindowsRegistry( File icon, OutputStream backupLog, RegistryOperations registryOps + ) { + this(appInfo, exe, icon, backupLog, registryOps, null); + } + + public InstallWindowsRegistry( + AppInfo appInfo, + File exe, + File icon, + OutputStream backupLog, + RegistryOperations registryOps, + InstallationLogger logger ) { this.appInfo = appInfo; this.exe = exe; this.icon = icon; this.registryOps = registryOps; + this.installationLogger = logger; if (backupLog != null) { this.backupLog = backupLog; this.backupLogOut = new PrintStream(backupLog); @@ -219,6 +243,27 @@ public void setSkipWinRegistryOperations(boolean skip) { this.skipWinRegistryOperations = skip; } + /** + * Helper method to log a registry key creation. + */ + private void logRegistryKeyCreated(String keyPath) { + if (installationLogger != null) { + installationLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_CREATED, + "HKCU\\" + keyPath); + } + } + + /** + * Helper method to log a registry value set. + */ + private void logRegistryValueSet(String keyPath, String valueName, String value) { + if (installationLogger != null) { + String details = valueName + " = " + (value != null && value.length() > 50 ? value.substring(0, 50) + "..." : value); + installationLogger.logRegistryOperation(InstallationLogger.RegistryOperation.VALUE_SET, + "HKCU\\" + keyPath, details); + } + } + public File getUninstallerPath() { String suffix = ""; if (appInfo.getNpmVersion() != null && appInfo.getNpmVersion().startsWith("0.0.0-")) { @@ -441,6 +486,7 @@ private void createKeyRecursive(String key) { } if (!registryOps.keyExists(key)) { registryOps.createKey(key); + logRegistryKeyCreated(key); } } @@ -802,26 +848,64 @@ private void unregisterDirectoryAssociations() { } public void unregister(File backupLogFile) throws IOException { + unregister(backupLogFile, null); + } + + public void unregister(InstallationLogger logger) throws IOException { + unregister(null, logger); + } + + public void unregister(File backupLogFile, InstallationLogger logger) throws IOException { + // Use passed logger or fall back to instance logger + InstallationLogger activeLogger = logger != null ? logger : this.installationLogger; + + if (activeLogger != null) { + activeLogger.logInfo("Starting registry unregistration"); + } if (appInfo.hasDocumentTypes()) { unregisterFileExtensions(); + if (activeLogger != null) { + activeLogger.logInfo("Unregistered file extensions"); + } } if (appInfo.hasUrlSchemes()) { unregisterUrlSchemes(); + if (activeLogger != null) { + activeLogger.logInfo("Unregistered URL schemes"); + } } if (appInfo.hasDirectoryAssociation()) { unregisterDirectoryAssociations(); + if (activeLogger != null) { + activeLogger.logInfo("Unregistered directory associations"); + } } deleteUninstallEntry(); + if (activeLogger != null) { + activeLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_DELETED, + "HKCU\\" + getUninstallKey(), "Uninstaller entry"); + } + deleteRegistryKey(); + if (activeLogger != null) { + activeLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_DELETED, + "HKCU\\" + getRegistryPath(), "Application registry key"); + } if (!skipWinRegistryOperations && backupLogFile != null && backupLogFile.exists()) { new WinRegistry().regImport(backupLogFile); + if (activeLogger != null) { + activeLogger.logInfo("Restored registry from backup: " + backupLogFile.getAbsolutePath()); + } } + if (activeLogger != null) { + activeLogger.logInfo("Registry unregistration complete"); + } } public void deleteRegistryKey() { @@ -884,11 +968,18 @@ private void deleteFileTypeEntry() { } public void register() throws IOException { + if (installationLogger != null) { + installationLogger.logInfo("Starting Windows registry operations"); + } + WinRegistry registry = new WinRegistry(); String registryPath = getRegistryPath(); String capabilitiesPath = getCapabilitiesPath(); if (!skipWinRegistryOperations && registryOps.keyExists(registryPath)) { registry.exportKey(registryPath, backupLog); + if (installationLogger != null) { + installationLogger.logInfo("Exported existing registry key for backup: " + registryPath); + } } // Ensure the base registry path exists for the application @@ -896,9 +987,12 @@ public void register() throws IOException { createKeyRecursive(capabilitiesPath); registryOps.setStringValue(capabilitiesPath, "ApplicationName", appInfo.getTitle()); + logRegistryValueSet(capabilitiesPath, "ApplicationName", appInfo.getTitle()); registryOps.setStringValue(capabilitiesPath, "ApplicationDescription", appInfo.getDescription()); + logRegistryValueSet(capabilitiesPath, "ApplicationDescription", appInfo.getDescription()); if (icon != null && icon.exists()) { registryOps.setStringValue(capabilitiesPath, "ApplicationIcon", icon.getAbsolutePath()+",0"); + logRegistryValueSet(capabilitiesPath, "ApplicationIcon", icon.getAbsolutePath()+",0"); } if (appInfo.hasDocumentTypes() || appInfo.hasUrlSchemes() || appInfo.hasDirectoryAssociation()) { @@ -929,25 +1023,38 @@ public void register() throws IOException { getRegisteredAppName(), getCapabilitiesPath() ); + logRegistryValueSet(registeredAppsKey, getRegisteredAppName(), getCapabilitiesPath()); // Now to register the uninstaller + if (installationLogger != null) { + installationLogger.logInfo("Registering uninstaller in Add/Remove Programs"); + } createKeyRecursive(getUninstallKey()); if (icon != null && icon.exists()) { registryOps.setStringValue(getUninstallKey(), "DisplayIcon", icon.getAbsolutePath() + ",0"); + logRegistryValueSet(getUninstallKey(), "DisplayIcon", icon.getAbsolutePath() + ",0"); } registryOps.setStringValue(getUninstallKey(), "DisplayName", appInfo.getTitle()); + logRegistryValueSet(getUninstallKey(), "DisplayName", appInfo.getTitle()); registryOps.setStringValue(getUninstallKey(), "DisplayVersion", appInfo.getVersion()); + logRegistryValueSet(getUninstallKey(), "DisplayVersion", appInfo.getVersion()); registryOps.setLongValue(getUninstallKey(), 1); registryOps.setStringValue(getUninstallKey(), "Publisher", appInfo.getVendor()); - registryOps.setStringValue( - getUninstallKey(), - "UninstallString", - "\"" + getUninstallerPath().getAbsolutePath() + "\" uninstall" - ); + logRegistryValueSet(getUninstallKey(), "Publisher", appInfo.getVendor()); + String uninstallString = "\"" + getUninstallerPath().getAbsolutePath() + "\" uninstall"; + registryOps.setStringValue(getUninstallKey(), "UninstallString", uninstallString); + logRegistryValueSet(getUninstallKey(), "UninstallString", uninstallString); if (!skipWinRegistryOperations) { registry.notifyFileAssociationsChanged(); + if (installationLogger != null) { + installationLogger.logInfo("Notified Windows of file association changes"); + } + } + + if (installationLogger != null) { + installationLogger.logInfo("Windows registry operations complete"); } diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java index 69bcfc1b..3960705b 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.installer.win; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.installer.logging.InstallationLogger; import ca.weblite.jdeploy.installer.util.PackagePathResolver; import ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller; import ca.weblite.tools.io.MD5; @@ -23,6 +24,7 @@ public class UninstallWindows { private String source; private String appFileName; + private InstallationLogger installationLogger; public UninstallWindows( String packageName, @@ -30,6 +32,17 @@ public UninstallWindows( String version, String appTitle, InstallWindowsRegistry installer + ) { + this(packageName, source, version, appTitle, installer, null); + } + + public UninstallWindows( + String packageName, + String source, + String version, + String appTitle, + InstallWindowsRegistry installer, + InstallationLogger logger ) { this.packageName = packageName; this.source = source; @@ -38,6 +51,7 @@ public UninstallWindows( this.installWindowsRegistry = installer; this.fullyQualifiedPackageName = installer.getFullyQualifiedPackageName(); this.appFileName = this.appTitle; + this.installationLogger = logger; if (version != null && version.startsWith("0.0.0-")) { this.appFileName += " " + version.substring(version.indexOf("-")+1); } @@ -120,6 +134,9 @@ private Iterable getVersionDirectories() { } private void deletePackage() throws IOException { + if (installationLogger != null) { + installationLogger.logSection("Deleting Package Files"); + } // Delete from all possible locations (architecture-specific and legacy) File[] allPossiblePaths = PackagePathResolver.getAllPossiblePackagePaths(packageName, version, source); @@ -133,12 +150,20 @@ private void deletePackage() throws IOException { !child.getName().startsWith("0.0.0-")) { System.out.println("Deleting version dir: " + child.getAbsolutePath()); FileUtils.deleteDirectory(child); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + child.getAbsolutePath(), "Version directory"); + } } } } else if (possiblePath.exists()) { // Delete specific version directory System.out.println("Deleting version dir: " + possiblePath.getAbsolutePath()); FileUtils.deleteDirectory(possiblePath); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + possiblePath.getAbsolutePath(), "Version directory"); + } } } } @@ -174,15 +199,26 @@ private void cleanupPackageDir() throws IOException { if (numVersionDirectoriesRemaining == 0) { System.out.println("Deleting package dir: " + packageDir.getAbsolutePath()); FileUtils.deleteDirectory(packageDir); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + packageDir.getAbsolutePath(), "Empty package directory"); + } } } } } private void deleteApp() throws IOException { + if (installationLogger != null) { + installationLogger.logSection("Deleting Application Directory"); + } if (getAppDirPath().exists()) { System.out.println("Deleting app dir: "+getAppDirPath().getAbsolutePath()); FileUtils.deleteDirectory(getAppDirPath()); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + getAppDirPath().getAbsolutePath(), "Application directory"); + } } } @@ -204,6 +240,10 @@ private void removeDesktopAlias() { if (getDesktopLink(suffix).exists()) { System.out.println("Deleting desktop link: "+getDesktopLink(suffix).getAbsolutePath()); getDesktopLink(suffix).delete(); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED, + getDesktopLink(suffix).getAbsolutePath(), null); + } } } } @@ -213,6 +253,10 @@ private void removeStartMenuLink() { if (getStartMenuLink(suffix).exists()) { System.out.println("Deleting start menu link: " + getStartMenuLink(suffix).getAbsolutePath()); getStartMenuLink(suffix).delete(); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED, + getStartMenuLink(suffix).getAbsolutePath(), null); + } } } } @@ -222,32 +266,63 @@ private void removeProgramsMenuLink() { if (getProgramsMenuLink(suffix).exists()) { System.out.println("Deleting programs menu link: " + getProgramsMenuLink(suffix).getAbsolutePath()); getProgramsMenuLink(suffix).delete(); + if (installationLogger != null) { + installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED, + getProgramsMenuLink(suffix).getAbsolutePath(), null); + } } } } public void uninstall() throws IOException { + if (installationLogger != null) { + installationLogger.logInfo("Starting uninstallation of " + appTitle + " version " + version); + installationLogger.logSection("Removing Shortcuts"); + } + removeDesktopAlias(); removeProgramsMenuLink(); removeStartMenuLink(); deletePackage(); cleanupPackageDir(); deleteApp(); - installWindowsRegistry.unregister(null); + + if (installationLogger != null) { + installationLogger.logSection("Removing Registry Entries"); + } + installWindowsRegistry.unregister(installationLogger); // Delegate CLI command cleanup to WindowsCliCommandInstaller + if (installationLogger != null) { + installationLogger.logSection("Removing CLI Commands"); + } File appDir = getAppDirPath(); try { WindowsCliCommandInstaller cliInstaller = new WindowsCliCommandInstaller(); + cliInstaller.setInstallationLogger(installationLogger); cliInstaller.uninstallCommands(appDir); } catch (Exception ex) { System.err.println("Warning: Failed to uninstall CLI commands: " + ex.getMessage()); + if (installationLogger != null) { + installationLogger.logError("Failed to uninstall CLI commands", ex); + } } + if (installationLogger != null) { + installationLogger.logSection("Cleanup"); + } File uninstallerJDeployFiles = new File(getUninstallerPath().getParentFile(), ".jdeploy-files"); if (uninstallerJDeployFiles.exists()) { FileUtils.deleteDirectory(uninstallerJDeployFiles); + if (installationLogger != null) { + installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED, + uninstallerJDeployFiles.getAbsolutePath(), "Uninstaller jDeploy files"); + } + } + + if (installationLogger != null) { + installationLogger.logInfo("Scheduling uninstaller self-deletion"); } scheduleDeleteUninstaller();